scheduled tasks
This commit is contained in:
parent
0fa627ca6d
commit
937827d08b
17 changed files with 1445 additions and 136 deletions
149
Cargo.lock
generated
149
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,53 +34,121 @@
|
||||||
</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>
|
||||||
|
|
||||||
<section id="answer-container" class="answer-container glass hidden">
|
<div class="task-content">
|
||||||
<div class="answer-header">
|
<aside class="run-history glass">
|
||||||
<h3>Final Answer</h3>
|
<div class="runs-header">
|
||||||
</div>
|
<h3>Run History</h3>
|
||||||
<div id="answer-output" class="answer-output">
|
|
||||||
<!-- Answer will be injected here -->
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="logs-container glass">
|
|
||||||
<div class="logs-header">
|
|
||||||
<h3>Execution Logs</h3>
|
|
||||||
<div class="logs-actions">
|
|
||||||
<span class="logs-timestamp" id="view-date">Feb 9, 2026</span>
|
|
||||||
</div>
|
</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">
|
||||||
|
<div class="answer-header">
|
||||||
|
<h3>Final Answer</h3>
|
||||||
|
</div>
|
||||||
|
<div id="answer-output" class="answer-output">
|
||||||
|
<!-- Answer will be injected here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="logs-container" class="logs-container glass">
|
||||||
|
<div class="logs-header">
|
||||||
|
<h3>Execution Logs</h3>
|
||||||
|
<div class="logs-actions">
|
||||||
|
<span class="logs-timestamp" id="view-date">Feb 9, 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="logs-output" class="logs-output">
|
||||||
|
<!-- Logs will be injected here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="logs-output" class="logs-output">
|
</div>
|
||||||
<!-- Logs will be injected here -->
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</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>
|
||||||
|
|
|
||||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
<li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}">
|
: new Date(a.created_at);
|
||||||
<div class="task-item-title">${task.goal}</div>
|
const bDate = b.runs && b.runs.length > 0
|
||||||
<div class="task-item-meta">
|
? new Date(b.runs[b.runs.length - 1].created_at)
|
||||||
<span class="status-dot ${task.status}"></span>
|
: new Date(b.created_at);
|
||||||
<span>${new Date(task.created_at).toLocaleDateString()}</span>
|
return bDate - aDate;
|
||||||
</div>
|
});
|
||||||
</li>
|
|
||||||
`
|
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}">
|
||||||
|
<div class="task-item-title">${task.goal}</div>
|
||||||
|
<div class="task-item-meta">
|
||||||
|
<span class="status-dot ${status}"></span>
|
||||||
|
<span>${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
method: 'POST',
|
if (state.isEditing) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}`, {
|
||||||
body: JSON.stringify({ goal }),
|
method: 'PUT',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const newTask = await response.json();
|
body: JSON.stringify({ goal, cron }),
|
||||||
state.tasks.unshift(newTask);
|
});
|
||||||
selectTask(newTask.id);
|
} 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) {
|
} 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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
migration/src/m20260210_000003_separate_runs.rs
Normal file
107
migration/src/m20260210_000003_separate_runs.rs
Normal 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,
|
||||||
|
}
|
||||||
35
migration/src/m20260210_000004_add_cron_column.rs
Normal file
35
migration/src/m20260210_000004_add_cron_column.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
pub mod task_run;
|
||||||
|
|
|
||||||
|
|
@ -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
36
src/entities/task_run.rs
Normal 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 {}
|
||||||
|
|
@ -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
118
src/scheduler.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/server.rs
229
src/server.rs
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/tools.rs
22
src/tools.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue