scheduled tasks

This commit is contained in:
pavel 2026-02-10 19:32:33 +01:00
commit 937827d08b
17 changed files with 1445 additions and 136 deletions

149
Cargo.lock generated
View file

@ -293,6 +293,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dashmap",
"migration", "migration",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
@ -300,6 +301,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-cron-scheduler",
"tower-http 0.5.2", "tower-http 0.5.2",
"uuid", "uuid",
] ]
@ -380,6 +382,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.57" version = "4.5.57"
@ -481,6 +493,17 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "croner"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576"
dependencies = [
"chrono",
"derive_builder",
"strum 0.27.2",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@ -526,6 +549,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim",
"syn 2.0.114", "syn 2.0.114",
] ]
@ -540,6 +564,20 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "dashmap"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -561,6 +599,37 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.114",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@ -886,6 +955,12 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -1446,6 +1521,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@ -1618,6 +1704,24 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@ -2098,7 +2202,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"strum", "strum 0.26.3",
"thiserror", "thiserror",
"time", "time",
"tracing", "tracing",
@ -2386,6 +2490,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -2674,6 +2784,27 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2864,6 +2995,22 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-cron-scheduler"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a"
dependencies = [
"chrono",
"chrono-tz",
"croner",
"num-derive",
"num-traits",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.6.0" version = "2.6.0"

View file

@ -18,3 +18,5 @@ sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"
sea-orm-migration = "1.1" sea-orm-migration = "1.1"
uuid = { version = "1.8", features = ["v4", "serde"] } uuid = { version = "1.8", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tokio-cron-scheduler = "0.15.1"
dashmap = "6.1.0"

View file

@ -17,10 +17,10 @@
<div id="app"> <div id="app">
<aside class="sidebar glass"> <aside class="sidebar glass">
<header class="sidebar-header"> <header class="sidebar-header">
<div class="logo"> <a href="#" id="logo-link" class="logo">
<span class="logo-icon"></span> <span class="logo-icon"></span>
<h1>Agency</h1> <h1>Agency</h1>
</div> </a>
<button id="new-task-btn" class="btn btn-primary"> <button id="new-task-btn" class="btn btn-primary">
<span>+</span> New Task <span>+</span> New Task
</button> </button>
@ -34,20 +34,63 @@
</aside> </aside>
<main class="main-content"> <main class="main-content">
<div id="empty-state" class="empty-state"> <div id="empty-state" class="empty-state hidden">
<div class="empty-icon"></div> <div class="empty-icon"></div>
<h2>Ready for a new directive?</h2> <h2>Ready for a new directive?</h2>
<p>Select a task from the sidebar or create a new one to begin.</p> <p>Select a task from the sidebar or create a new one to begin.</p>
</div> </div>
<div id="dashboard-view" class="dashboard-view">
<header class="dashboard-header">
<h2>Recent Activity</h2>
<p>Track latest agent executions across all directives.</p>
</header>
<div class="dashboard-content glass">
<table class="activity-table">
<thead>
<tr>
<th>Directive</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody id="recent-runs-list">
<!-- Recent runs will be injected here -->
</tbody>
</table>
</div>
</div>
<div id="task-view" class="task-view hidden"> <div id="task-view" class="task-view hidden">
<header class="view-header"> <header class="view-header">
<div class="header-main"> <div class="header-main">
<div id="view-status" class="status-badge">Running</div> <div id="view-status" class="status-badge">Running</div>
<h2 id="view-goal">Researching local LLM performance trends</h2> <h2 id="view-goal">Researching local LLM performance trends</h2>
</div> </div>
<div class="header-actions">
<button id="toggle-logs-btn" class="btn btn-ghost">
<span></span> Inspect Logs
</button>
<button id="edit-task-btn" class="btn btn-ghost">
<span></span> Edit Goal
</button>
<button id="rerun-btn" class="btn btn-ghost">
<span></span> Re-run Task
</button>
</div>
</header> </header>
<div class="task-content">
<aside class="run-history glass">
<div class="runs-header">
<h3>Run History</h3>
</div>
<ul id="run-list">
<!-- Runs will be injected here -->
</ul>
</aside>
<div class="run-details">
<section id="answer-container" class="answer-container glass hidden"> <section id="answer-container" class="answer-container glass hidden">
<div class="answer-header"> <div class="answer-header">
<h3>Final Answer</h3> <h3>Final Answer</h3>
@ -57,7 +100,7 @@
</div> </div>
</section> </section>
<section class="logs-container glass"> <section id="logs-container" class="logs-container glass">
<div class="logs-header"> <div class="logs-header">
<h3>Execution Logs</h3> <h3>Execution Logs</h3>
<div class="logs-actions"> <div class="logs-actions">
@ -69,18 +112,43 @@
</div> </div>
</section> </section>
</div> </div>
</div>
</div>
<div id="modal-container" class="modal-container hidden"> <div id="modal-container" class="modal-container hidden">
<div class="modal glass"> <div class="modal glass">
<h2>New Agent Task</h2> <h2 id="modal-title">New Agent Task</h2>
<p>Provide a goal for the agent to execute.</p> <p>Provide a goal for the agent to execute.</p>
<form id="new-task-form"> <form id="new-task-form">
<textarea name="goal" <textarea name="goal" id="goal-textarea"
placeholder="e.g. Research the latest trending AI agents and write a report..." placeholder="e.g. Research the latest trending AI agents and write a report..."
required></textarea> required></textarea>
<div class="form-group">
<label>Execution Schedule</label>
<div id="schedule-presets" class="preset-group">
<button type="button" class="btn btn-preset active" data-cron="">One-time</button>
<button type="button" class="btn btn-preset" data-cron="0 */5 * * * *">Every 5m</button>
<button type="button" class="btn btn-preset" data-cron="0 0 * * * *">Hourly</button>
<button type="button" class="btn btn-preset" data-cron="0 0 0 * * *">Daily</button>
</div>
<div class="custom-cron-toggle">
<button type="button" id="toggle-custom-cron" class="btn-link">Manual Cron
expression...</button>
</div>
<div id="custom-cron-container" class="hidden">
<input type="text" name="cron" id="cron-input"
placeholder="e.g. 0 * * * * * (every minute)">
<small class="form-help">Uses standard cron syntax (seconds included).</small>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" id="cancel-task">Cancel</button> <button type="button" class="btn btn-ghost" id="cancel-task">Cancel</button>
<button type="submit" class="btn btn-primary">Execute Directive</button> <button type="submit" class="btn btn-primary" id="submit-task-btn">Execute
Directive</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -9,7 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"lucide-static": "^0.563.0" "dompurify": "^3.3.1",
"lucide-static": "^0.563.0",
"marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"vite": "^7.3.1" "vite": "^7.3.1"
@ -814,6 +816,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@ -895,6 +913,18 @@
"integrity": "sha512-O7ZlK+VFfc/G5KYwUb9jO60RlPNKV6Dyu+OgQC/QCiO4FMPIK9qrIIkYHHG6PLgLKvmELmf1eKSAso+D/vAvcg==", "integrity": "sha512-O7ZlK+VFfc/G5KYwUb9jO60RlPNKV6Dyu+OgQC/QCiO4FMPIK9qrIIkYHHG6PLgLKvmELmf1eKSAso+D/vAvcg==",
"license": "ISC" "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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",

View file

@ -14,6 +14,8 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"lucide-static": "^0.563.0" "dompurify": "^3.3.1",
"lucide-static": "^0.563.0",
"marked": "^17.0.1"
} }
} }

View file

@ -1,8 +1,14 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const API_URL = 'http://localhost:3000'; const API_URL = 'http://localhost:3000';
const state = { const state = {
tasks: [], tasks: [],
selectedTaskId: null, selectedTaskId: null,
selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false,
}; };
// DOM elements // DOM elements
@ -19,17 +25,59 @@ const viewStatusEl = document.getElementById('view-status');
const viewDateEl = document.getElementById('view-date'); const viewDateEl = document.getElementById('view-date');
const answerContainerEl = document.getElementById('answer-container'); const answerContainerEl = document.getElementById('answer-container');
const answerOutputEl = document.getElementById('answer-output'); 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() { async function fetchTasks() {
try { try {
const response = await fetch(`${API_URL}/tasks`); 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(); 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) { 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 (task) {
if (shouldFollowLatest && task.runs && task.runs.length > 0) {
state.selectedRunId = task.runs[task.runs.length - 1].id;
}
renderRunHistory(task);
showTaskView(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() { function renderTaskList() {
taskListEl.innerHTML = state.tasks const sortedTasks = [...state.tasks].sort((a, b) => {
.map( const aDate = a.runs && a.runs.length > 0
(task) => ` ? 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 `
<li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}"> <li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}">
<div class="task-item-title">${task.goal}</div> <div class="task-item-title">${task.goal}</div>
<div class="task-item-meta"> <div class="task-item-meta">
<span class="status-dot ${task.status}"></span> <span class="status-dot ${status}"></span>
<span>${new Date(task.created_at).toLocaleDateString()}</span> <span>${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div> </div>
</li> </li>
` `;
) })
.join(''); .join('');
// Add event listeners // Add event listeners
@ -61,31 +135,120 @@ function renderTaskList() {
}); });
} }
function selectTask(id) { function selectTask(id, runId = null) {
state.selectedTaskId = id; state.selectedTaskId = id;
state.currentView = 'task';
const task = state.tasks.find((t) => t.id === id); const task = state.tasks.find((t) => t.id === id);
if (!task) return; 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(); renderTaskList();
renderRunHistory(task);
showTaskView(task); showTaskView(task);
} }
function showTaskView(task) { function renderRunHistory(task) {
runListEl.innerHTML = task.runs
.slice()
.reverse()
.map(
(run, index) => `
<li class="run-item ${state.selectedRunId === run.id ? 'active' : ''}" data-id="${run.id}">
<span>Run #${task.runs.length - index}</span>
<span class="run-item-date">${new Date(run.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</li>
`
)
.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'); emptyStateEl.classList.add('hidden');
taskViewEl.classList.add('hidden');
dashboardViewEl.classList.remove('hidden');
renderTaskList();
fetchRecentRuns();
}
function renderDashboard(recentRuns) {
recentRunsListEl.innerHTML = recentRuns
.map(
(run) => `
<tr data-task-id="${run.task_id}" data-run-id="${run.id}">
<td>${escapeHtml(run.goal)}</td>
<td><span class="status-badge ${run.status}">${run.status}</span></td>
<td>${new Date(run.created_at).toLocaleString()}</td>
</tr>
`
)
.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'); taskViewEl.classList.remove('hidden');
const run = task.runs.find(r => r.id === state.selectedRunId) || task.runs[task.runs.length - 1];
viewGoalEl.textContent = task.goal; viewGoalEl.textContent = task.goal;
viewStatusEl.textContent = task.status;
viewStatusEl.className = `status-badge ${task.status}`; // Show schedule info if exists
viewDateEl.textContent = new Date(task.created_at).toLocaleDateString(undefined, { const scheduleInfo = task.cron ? `<div class="schedule-badge">🕒 Scheduled: ${task.cron}</div>` : '';
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', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric' year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}); });
if (task.answer) { if (run.answer) {
answerContainerEl.classList.remove('hidden'); answerContainerEl.classList.remove('hidden');
answerOutputEl.textContent = task.answer; const rawHtml = marked.parse(run.answer);
answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml);
} else { } else {
answerContainerEl.classList.add('hidden'); answerContainerEl.classList.add('hidden');
} }
@ -95,7 +258,7 @@ function showTaskView(task) {
const isFirstLoad = logsOutputEl.innerHTML === ''; const isFirstLoad = logsOutputEl.innerHTML === '';
// Simple log format // Simple log format
logsOutputEl.innerHTML = task.logs logsOutputEl.innerHTML = run.logs
.split('\n') .split('\n')
.map((line) => `<div class="log-entry">${escapeHtml(line)}</div>`) .map((line) => `<div class="log-entry">${escapeHtml(line)}</div>`)
.join(''); .join('');
@ -112,31 +275,140 @@ function escapeHtml(text) {
} }
// Event Listeners // 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', () => { 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 ? '<span>⌨</span> Inspect Logs' : '<span>✕</span> 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'); modalContainer.classList.remove('hidden');
}); });
cancelTaskBtn.addEventListener('click', () => { cancelTaskBtn.addEventListener('click', () => {
modalContainer.classList.add('hidden'); 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) => { newTaskForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(newTaskForm); const formData = new FormData(newTaskForm);
const goal = formData.get('goal'); const goal = formData.get('goal');
const cron = formData.get('cron') || null;
modalContainer.classList.add('hidden'); modalContainer.classList.add('hidden');
newTaskForm.reset(); newTaskForm.reset();
try { try {
const response = await fetch(`${API_URL}/tasks`, { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal }), body: JSON.stringify({ goal, cron }),
}); });
const newTask = await response.json(); }
state.tasks.unshift(newTask); const updatedTask = await response.json();
selectTask(newTask.id);
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) { } catch (error) {
console.error('Error creating task:', error); console.error('Error creating task:', error);
alert('Failed to execute task. Check console.'); alert('Failed to execute task. Check console.');
@ -144,6 +416,21 @@ newTaskForm.addEventListener('submit', async (e) => {
}); });
// Initial load // Initial load
// Initial fetch
fetchTasks(); 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();

View file

@ -54,6 +54,80 @@ body {
border-bottom: 1px solid var(--glass-border); 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 { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
@ -157,6 +231,9 @@ body {
.view-header { .view-header {
margin-bottom: 32px; margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
} }
.header-main { .header-main {
@ -165,6 +242,75 @@ body {
gap: 16px; 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 { .status-badge {
padding: 4px 12px; padding: 4px 12px;
border-radius: 100px; border-radius: 100px;
@ -186,13 +332,28 @@ body {
border: 1px solid rgba(81, 207, 102, 0.2); 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 { .logs-container {
flex: 1; width: 450px;
min-height: 200px;
border-radius: 12px; border-radius: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; 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 { .logs-header {
@ -226,11 +387,10 @@ body {
/* Answer Section */ /* Answer Section */
.answer-container { .answer-container {
margin-bottom: 32px; flex: 1;
border-radius: 12px; border-radius: 12px;
border-left: 4px solid var(--primary); border-left: 4px solid var(--primary);
background: linear-gradient(to right, rgba(93, 93, 255, 0.05), transparent); background: linear-gradient(to right, rgba(93, 93, 255, 0.05), transparent);
max-height: 40vh;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -255,10 +415,93 @@ body {
.answer-output { .answer-output {
padding: 24px; padding: 24px;
font-size: 16px; line-height: 1.6;
line-height: 1.7; color: var(--text);
color: var(--text-main); font-size: 15px;
white-space: pre-wrap; }
.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 */ /* Buttons */
@ -344,6 +587,104 @@ textarea:focus {
border-color: var(--primary); 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 { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View file

@ -2,6 +2,8 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table; mod m20220101_000001_create_table;
mod m20260210_000002_add_answer_column; mod m20260210_000002_add_answer_column;
mod m20260210_000003_separate_runs;
mod m20260210_000004_add_cron_column;
pub struct Migrator; pub struct Migrator;
@ -11,6 +13,8 @@ impl MigratorTrait for Migrator {
vec![ vec![
Box::new(m20220101_000001_create_table::Migration), Box::new(m20220101_000001_create_table::Migration),
Box::new(m20260210_000002_add_answer_column::Migration), Box::new(m20260210_000002_add_answer_column::Migration),
Box::new(m20260210_000003_separate_runs::Migration),
Box::new(m20260210_000004_add_cron_column::Migration),
] ]
} }
} }

View file

@ -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,
}

View file

@ -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,
}

View file

@ -1 +1,2 @@
pub mod task; pub mod task;
pub mod task_run;

View file

@ -7,15 +7,20 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,
pub goal: String, pub goal: String,
pub status: String, pub cron: Option<String>,
#[sea_orm(column_type = "Text")]
pub logs: String,
#[sea_orm(column_type = "Text", nullable)]
pub answer: Option<String>,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {} pub enum Relation {
#[sea_orm(has_many = "super::task_run::Entity")]
TaskRuns,
}
impl Related<super::task_run::Entity> for Entity {
fn to() -> RelationDef {
Relation::TaskRuns.def()
}
}
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}

36
src/entities/task_run.rs Normal file
View file

@ -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<String>,
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<super::task::Entity> for Entity {
fn to() -> RelationDef {
Relation::Task.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,6 +1,7 @@
mod agent; mod agent;
mod api; mod api;
mod entities; mod entities;
mod scheduler;
mod server; mod server;
mod tools; mod tools;

118
src/scheduler.rs Normal file
View file

@ -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<Uuid, Uuid>,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
}
impl Scheduler {
pub async fn new(
db: DatabaseConnection,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
) -> Result<Self, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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<String>,
tavily_key: Option<String>,
task_id: Uuid,
) -> Result<(), Box<dyn std::error::Error>> {
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(())
}
}

View file

@ -1,23 +1,28 @@
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, State},
http::{Method, StatusCode}, http::StatusCode,
routing::{get, post}, routing::{get, post},
}; };
use chrono::Utc; 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 serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::CorsLayer;
use uuid::Uuid; use uuid::Uuid;
use crate::agent::Agent; use crate::agent::Agent;
use crate::entities::task::{self, Entity as Task}; 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}; use migration::{Migrator, MigratorTrait};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: DatabaseConnection, pub db: DatabaseConnection,
pub scheduler: Arc<Scheduler>,
pub zen_api_key: Option<String>, pub zen_api_key: Option<String>,
pub tavily_api_key: Option<String>, pub tavily_api_key: Option<String>,
} }
@ -25,18 +30,42 @@ pub struct AppState {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTaskRequest { pub struct CreateTaskRequest {
pub goal: String, pub goal: String,
pub cron: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateTaskRequest {
pub goal: String,
pub cron: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct TaskResponse { pub struct TaskResponse {
pub id: Uuid, pub id: Uuid,
pub goal: String, pub goal: String,
pub cron: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
pub runs: Vec<TaskRunResponse>,
}
#[derive(Serialize)]
pub struct TaskRunResponse {
pub id: Uuid,
pub status: String, pub status: String,
pub logs: String, pub logs: String,
pub answer: Option<String>, pub answer: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>, pub created_at: chrono::DateTime<chrono::FixedOffset>,
} }
#[derive(Serialize)]
pub struct RecentRunResponse {
pub id: Uuid,
pub task_id: Uuid,
pub goal: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> { pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = Database::connect(db_url).await?; let db = Database::connect(db_url).await?;
Migrator::up(&db, None).await?; Migrator::up(&db, None).await?;
@ -44,20 +73,31 @@ pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let zen_api_key = std::env::var("ZEN_API_KEY").ok(); let zen_api_key = std::env::var("ZEN_API_KEY").ok();
let tavily_api_key = std::env::var("TAVILY_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 { let state = Arc::new(AppState {
db, db,
scheduler,
zen_api_key, zen_api_key,
tavily_api_key, tavily_api_key,
}); });
let cors = CorsLayer::new() let cors = CorsLayer::permissive();
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
let app = Router::new() let app = Router::new()
.route("/tasks", post(create_task).get(list_tasks)) .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) .layer(cors)
.with_state(state); .with_state(state);
@ -72,6 +112,7 @@ async fn list_tasks(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> { ) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> {
let tasks = Task::find() let tasks = Task::find()
.find_with_related(TaskRun)
.order_by_desc(task::Column::CreatedAt) .order_by_desc(task::Column::CreatedAt)
.all(&state.db) .all(&state.db)
.await .await
@ -79,13 +120,21 @@ async fn list_tasks(
let response = tasks let response = tasks
.into_iter() .into_iter()
.map(|t| TaskResponse { .map(|(t, runs)| TaskResponse {
id: t.id, id: t.id,
goal: t.goal, goal: t.goal,
status: t.status, cron: t.cron,
logs: t.logs,
answer: t.answer,
created_at: t.created_at, 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(); .collect();
@ -98,13 +147,11 @@ async fn create_task(
) -> Result<Json<TaskResponse>, (StatusCode, String)> { ) -> Result<Json<TaskResponse>, (StatusCode, String)> {
let task_id = Uuid::new_v4(); let task_id = Uuid::new_v4();
// Initial save // Initial task save
let new_task = task::ActiveModel { let new_task = task::ActiveModel {
id: Set(task_id), id: Set(task_id),
goal: Set(payload.goal.clone()), goal: Set(payload.goal.clone()),
status: Set("running".to_string()), cron: Set(payload.cron.clone()),
logs: Set(String::new()),
answer: Set(None),
created_at: Set(Utc::now().into()), created_at: Set(Utc::now().into()),
}; };
@ -113,14 +160,54 @@ async fn create_task(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Run agent in the background or synchronously for now as requested if let Some(cron) = &payload.cron {
// "it can be created and executed on the agent with an api. the resulting logs after execution will get returned by the endpoint" let _ = state.scheduler.add_task_job(task_id, cron).await;
// This implies we wait for it to finish. } 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<TaskResponse>, (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<AppState>,
task_id: Uuid,
goal: String,
) -> Result<Json<TaskResponse>, (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( let mut agent = Agent::new(
state.zen_api_key.clone(), state.zen_api_key.clone(),
state.tavily_api_key.clone(), state.tavily_api_key.clone(),
payload.goal.clone(), goal.clone(),
); );
let (logs, answer) = match agent.run().await { let (logs, answer) = match agent.run().await {
@ -129,51 +216,111 @@ async fn create_task(
}; };
// Update with final logs and status // 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) .one(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or(( .ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"Task not found after insert".to_string(), "Run not found after insert".to_string(),
))? ))?
.into(); .into();
task.logs = Set(logs.clone()); run.logs = Set(logs.clone());
task.answer = Set(answer.clone()); run.answer = Set(answer.clone());
task.status = Set("completed".to_string()); run.status = Set("completed".to_string());
let updated_task = task run.update(&state.db)
.update(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(TaskResponse { get_task(Path(task_id), State(state)).await
id: updated_task.id, }
goal: updated_task.goal,
status: updated_task.status, async fn update_task(
logs: updated_task.logs, State(state): State<Arc<AppState>>,
answer: updated_task.answer, Path(id): Path<Uuid>,
created_at: updated_task.created_at, Json(payload): Json<UpdateTaskRequest>,
})) ) -> Result<Json<TaskResponse>, (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( async fn get_task(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> { ) -> Result<Json<TaskResponse>, (StatusCode, String)> {
let task = Task::find_by_id(id) let results = Task::find_by_id(id)
.one(&state.db) .find_with_related(TaskRun)
.all(&state.db)
.await .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_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
Ok(Json(TaskResponse { Ok(Json(TaskResponse {
id: task.id, id: task.id,
goal: task.goal, goal: task.goal,
status: task.status, cron: task.cron,
logs: task.logs,
answer: task.answer,
created_at: task.created_at, 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<Arc<AppState>>,
) -> Result<Json<Vec<RecentRunResponse>>, (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))
}

View file

@ -20,28 +20,6 @@ pub fn get_tools() -> Vec<Tool> {
}), }),
}, },
}, },
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 {
tool_type: "function".to_string(), tool_type: "function".to_string(),
function: FunctionDefinition { function: FunctionDefinition {