Compare commits

...

37 commits

Author SHA1 Message Date
f9e976c224 a
All checks were successful
/ upload (release) Successful in 36s
2026-02-20 18:42:59 +01:00
7384bcaf40 idk
All checks were successful
/ upload (release) Successful in 41s
2026-02-20 18:00:43 +01:00
9aca4cadf0 stuff
All checks were successful
/ upload (release) Successful in 40s
2026-02-17 19:07:06 +01:00
d3cdcea0df a
All checks were successful
/ upload (release) Successful in 35s
2026-02-12 21:50:29 +01:00
42a78c529d notification links
All checks were successful
/ upload (release) Successful in 35s
2026-02-12 21:26:41 +01:00
779500d22e fix site load
All checks were successful
/ upload (release) Successful in 37s
2026-02-12 20:53:50 +01:00
0dc8251ec7 a
All checks were successful
/ upload (release) Successful in 30s
2026-02-12 02:12:42 +01:00
0e41f57ce5 a
All checks were successful
/ upload (release) Successful in 31s
2026-02-12 02:08:05 +01:00
6f6827d4d9 a
All checks were successful
/ upload (release) Successful in 31s
2026-02-12 02:02:51 +01:00
37986f8b0b puush
All checks were successful
/ upload (release) Successful in 33s
2026-02-12 01:51:39 +01:00
e7d1dbe849 pushh
All checks were successful
/ upload (release) Successful in 31s
2026-02-12 01:45:26 +01:00
8d19873738 push
All checks were successful
/ upload (release) Successful in 31s
2026-02-12 01:34:24 +01:00
91db5861c8 push notifications
All checks were successful
/ upload (release) Successful in 1m4s
2026-02-12 01:28:24 +01:00
cc8fcd682b build cache
All checks were successful
/ upload (release) Successful in 2m3s
2026-02-12 00:53:11 +01:00
a1dbb328e0 pwa 2026-02-12 00:50:18 +01:00
f8415c3441 mobil
All checks were successful
/ upload (release) Successful in 1m43s
2026-02-12 00:39:53 +01:00
12098aaa8a mobil
All checks were successful
/ upload (release) Successful in 1m45s
2026-02-12 00:36:22 +01:00
17261c86b7 mobile
All checks were successful
/ upload (release) Successful in 1m44s
2026-02-12 00:29:09 +01:00
a5c5a9349f mobile friendly
All checks were successful
/ upload (release) Successful in 1m46s
2026-02-11 18:54:12 +01:00
006836b03c fix
All checks were successful
/ upload (release) Successful in 1m46s
2026-02-11 17:55:09 +01:00
beab32894b idk 2026-02-11 17:45:04 +01:00
bab956387c stuff 2026-02-11 17:09:49 +01:00
f0b53b67ce websockets 2026-02-11 16:55:55 +01:00
b44e4ed6f9 chat agent 2026-02-11 16:43:05 +01:00
b9c6c831c1 improve logging
All checks were successful
/ upload (release) Successful in 1m42s
2026-02-11 01:58:01 +01:00
ce5d2c9fb4 more refactoring 2026-02-11 01:51:44 +01:00
7268d49b4a more refactoring 2026-02-11 01:41:07 +01:00
13e17770ca refactoring 2026-02-11 01:29:23 +01:00
04ece6afb1 refactor config 2026-02-11 01:15:56 +01:00
d90052cfd1 fix 2026-02-11 01:04:46 +01:00
39143e1998 fix: stuff 2026-02-11 00:56:33 +01:00
6e1251b136 fix: use cookies 2026-02-11 00:51:43 +01:00
4dc3d84d38 fix: improve task status 2026-02-11 00:26:47 +01:00
886d408c3b fix: terminate stuck agent 2026-02-11 00:23:57 +01:00
fcad49bf7a fix: cors 2026-02-11 00:18:03 +01:00
844677cc27 fix: token validation 2026-02-11 00:14:27 +01:00
37f244e46b fix token refresh
All checks were successful
/ upload (release) Successful in 1m38s
2026-02-11 00:00:02 +01:00
43 changed files with 5342 additions and 950 deletions

View file

@ -11,6 +11,31 @@ jobs:
with: with:
node-version: 24 node-version: 24
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: frontend/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache Cargo target
uses: actions/cache@v4
with:
path: target/
key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-target-
- run: | - run: |
cd frontend cd frontend
npm install npm install

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/target /target
node_modules node_modules
/frontend/dist /frontend/dist
.env

154
AGENTS.md Normal file
View file

@ -0,0 +1,154 @@
# AGENTS.md
## Overview
This is **Antigravity Agent** — an autonomous AI agent platform built with a Rust backend and a vanilla JS frontend. Users create "tasks" (goals), and the system dispatches an LLM-powered agent to accomplish them. Tasks can be run on-demand or on a cron schedule. Each execution produces logs and a final answer, all persisted to PostgreSQL.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Frontend (Vite + Vanilla JS) │
│ - OAuth login via Authentik │
│ - Dashboard: recent runs, task list, logs, answers │
│ - Polls /api/tasks every 3s for live updates │
└──────────────────────┬──────────────────────────────────┘
│ /api/*
┌──────────────────────▼──────────────────────────────────┐
│ Axum HTTP Server (src/server/) │
│ - CORS, CSP, rate limiting (1 MB body) │
│ - Cookie-based + Bearer token auth │
│ - Routes: tasks CRUD, runs, auth (callback/refresh) │
├─────────────────────────────────────────────────────────┤
│ Domain Layer (src/domain/) │
│ ├─ agent/ Agent loop, LLM API client, tool defs │
│ ├─ auth JwksVerifier, Authenticator (OIDC) │
│ └─ tasks Task execution, run management │
├─────────────────────────────────────────────────────────┤
│ Scheduler (src/scheduler.rs) │
│ - tokio-cron-scheduler for recurring task execution │
├─────────────────────────────────────────────────────────┤
│ Entities (src/entities/) │
│ - task, task_run (SeaORM models) │
├─────────────────────────────────────────────────────────┤
│ PostgreSQL │
│ - Migrations managed via sea-orm-migration │
└─────────────────────────────────────────────────────────┘
```
## Project Structure
```
bot/
├── Cargo.toml # Workspace root (members: ".", "migration")
├── src/
│ ├── main.rs # Entrypoint: tracing init → server::start()
│ ├── config.rs # Config struct loaded from env vars
│ ├── error.rs # AppError enum (thiserror) → Axum responses
│ ├── scheduler.rs # Cron job scheduler (wraps tokio-cron-scheduler)
│ ├── tests.rs # Unit tests for error handling & config
│ ├── domain/
│ │ ├── agent/
│ │ │ ├── mod.rs # Agent struct: agentic loop with turn/time limits
│ │ │ ├── api.rs # LLM request/response types, Tavily search client
│ │ │ └── tools.rs # Tool definitions (google_search, finish) & dispatch
│ │ ├── auth.rs # JwksVerifier (RSA/JWKS), Authenticator (code exchange, refresh)
│ │ └── tasks.rs # Task execution logic, response DTOs
│ ├── entities/
│ │ ├── task.rs # SeaORM entity: tasks table
│ │ └── task_run.rs # SeaORM entity: task_runs table (belongs_to task)
│ └── server/
│ ├── mod.rs # App bootstrap: DB, scheduler, auth, router, CORS
│ ├── auth.rs # Auth routes & AuthenticatedUser extractor
│ └── tasks.rs # Task CRUD & run endpoints
├── migration/
│ └── src/ # SeaORM migrations (tasks, answer col, runs table, cron col)
├── frontend/
│ ├── index.html # SPA shell with glassmorphism dark theme
│ ├── src/
│ │ ├── main.js # All app logic: auth flow, task/run rendering, polling
│ │ └── style.css # Styles
│ ├── vite.config.js # Dev proxy: /api → localhost:3000
│ └── package.json # Deps: vite, marked, dompurify
└── .forgejo/workflows/
└── pipeline.yaml # CI: build frontend + cargo build → deploy via systemd
```
## Agent System
The agent (`src/domain/agent/`) is a turn-based autonomous loop:
1. A system prompt is injected with the current date and instructions not to ask the user for clarification.
2. The user's goal is sent as the initial message.
3. Each turn calls the **Kimi K2.5** model via the Zen API (`https://opencode.ai/zen/v1/chat/completions`).
4. The model can invoke tools:
- **`google_search`** — web search via the Tavily API.
- **`finish`** — signals completion and provides the final answer.
5. Tool results are appended to the conversation and the loop continues.
6. The loop terminates when `finish` is called, the turn limit is hit (`AGENT_MAX_TURNS`, default 20), or the time limit expires (`AGENT_MAX_DURATION_SECS`, default 120s).
All turns and tool calls are logged. The final answer (if any) and the full log are persisted to the `task_runs` table.
## Environment Variables
| Variable | Required | Default | Description |
|---------------------------|----------|---------|----------------------------------------------|
| `DATABASE_URL` | ✅ | — | PostgreSQL connection string |
| `PORT` | | `3000` | HTTP server port |
| `ZEN_API_KEY` | | — | API key for Zen/Kimi LLM |
| `TAVILY_API_KEY` | | — | API key for Tavily web search |
| `AUTHENTIK_ISSUER` | ✅ | — | OIDC issuer URL (Authentik) |
| `AUTHENTIK_CLIENT_ID` | ✅ | — | OAuth client ID |
| `AUTHENTIK_CLIENT_SECRET` | ✅ | — | OAuth client secret |
| `CORS_ALLOWED_ORIGINS` | | — | Comma-separated allowed origins (or mirror) |
| `COOKIE_SECURE` | | `false` | Set `true` for HTTPS-only cookies |
| `AGENT_MAX_TURNS` | | `20` | Max LLM turns per agent run |
| `AGENT_MAX_DURATION_SECS` | | `120` | Max wall-clock seconds per agent run |
## API Routes
All task/run routes require authentication (cookie or Bearer token).
| Method | Path | Description |
|--------|------------------------|-----------------------------------|
| GET | `/api/tasks` | List all tasks with their runs |
| POST | `/api/tasks` | Create a new task |
| GET | `/api/tasks/:id` | Get a single task with runs |
| PUT | `/api/tasks/:id` | Update task goal/cron schedule |
| POST | `/api/tasks/:id/runs` | Trigger a manual re-run |
| GET | `/api/runs/recent` | Latest 50 runs across all tasks |
| GET | `/api/auth/session` | Check current session |
| GET | `/api/auth/callback` | OAuth code → token exchange |
| POST | `/api/auth/refresh` | Refresh access token |
| POST | `/api/auth/logout` | Clear auth cookies |
## Database Schema
**`tasks`** — `id` (UUID PK), `goal` (text), `cron` (text, nullable), `created_at` (timestamptz)
**`task_runs`** — `id` (UUID PK), `task_id` (FK → tasks, cascade delete), `status` (text), `logs` (text), `answer` (text, nullable), `created_at` (timestamptz)
Migrations are in `migration/src/` and run automatically on startup via `Migrator::up()`.
## Development
```bash
# Backend (from repo root)
cargo run # requires DATABASE_URL + Authentik vars
# Frontend (from frontend/)
npm install
npm run dev # Vite dev server on :5173, proxies /api to :3000
# Tests
cargo test
```
## CI / Deployment
The Forgejo Actions pipeline (`.forgejo/workflows/pipeline.yaml`) triggers on release publish:
1. Builds the frontend (`npm install && npm run build`).
2. Builds the Rust binary (`cargo build -r`).
3. Uploads the binary as a release asset.
4. Deploys to the host: copies binary + frontend dist, restarts the `bot` systemd user service.

813
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,8 @@ tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
axum = "0.7" axum = { version = "0.7", features = ["ws"] }
tower-http = { version = "0.5", features = ["cors"] } tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] } sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = "1.1" sea-orm-migration = "1.1"
uuid = { version = "1.8", features = ["v4", "serde"] } uuid = { version = "1.8", features = ["v4", "serde"] }
@ -22,4 +22,11 @@ tokio-cron-scheduler = "0.15.1"
dashmap = "6.1.0" dashmap = "6.1.0"
jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] } jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
base64 = "0.22.1" base64 = "0.22.1"
axum-extra = { version = "0.9", features = ["typed-header"] } web-push = { version = "0.10.0", features = ["isahc-client"] }
isahc = "1.7"
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
cookie = "0.18"
thiserror = "2.0.18"
dotenvy = "0.15.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -3,9 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0a0a0c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
<title>Antigravity Agent Dashboard</title> <title>Antigravity Agent Dashboard</title>
<link rel="stylesheet" href="/src/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link <link
@ -54,6 +58,14 @@
</footer> </footer>
</aside> </aside>
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
<button id="menu-toggle" class="menu-toggle" aria-label="Toggle Menu">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</button>
<main class="main-content"> <main class="main-content">
<div id="empty-state" class="empty-state hidden"> <div id="empty-state" class="empty-state hidden">
<div class="empty-icon"></div> <div class="empty-icon"></div>
@ -63,9 +75,41 @@
<div id="dashboard-view" class="dashboard-view"> <div id="dashboard-view" class="dashboard-view">
<header class="dashboard-header"> <header class="dashboard-header">
<h2>Recent Activity</h2> <h2>Perspective</h2>
<p>Track latest agent executions across all directives.</p> <p>Overview of recent agent directives and live communication.</p>
<div class="dashboard-tabs">
<button class="tab-btn active" data-tab="chat">
<span class="tab-icon">💬</span> Quick Chat
</button>
<button class="tab-btn" data-tab="activity">
<span class="tab-icon">📊</span> Recent Activity
</button>
</div>
</header> </header>
<div class="dashboard-content-grid">
<div id="chat-tab-panel" class="tab-panel active">
<div id="chat-container" class="chat-container glass">
<div class="chat-header">
<h3>Neural Link</h3>
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear Memory</button>
</div>
<div id="chat-messages" class="chat-messages">
<!-- Chat messages will be injected here -->
<div class="chat-empty-state">
<p>Establish connection with the neural assistant.</p>
</div>
</div>
<form id="chat-form" class="chat-form">
<input type="text" id="chat-input" placeholder="Transmit message..." autocomplete="off"
required>
<button type="submit" id="chat-send-btn" class="btn btn-primary btn-sm">Send</button>
</form>
</div>
</div>
<div id="activity-tab-panel" class="tab-panel">
<div class="dashboard-content glass"> <div class="dashboard-content glass">
<table class="activity-table"> <table class="activity-table">
<thead> <thead>
@ -81,6 +125,8 @@
</table> </table>
</div> </div>
</div> </div>
</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">
@ -98,6 +144,9 @@
<button id="rerun-btn" class="btn btn-ghost"> <button id="rerun-btn" class="btn btn-ghost">
<span></span> Re-run Task <span></span> Re-run Task
</button> </button>
<button id="notify-task-btn" class="btn btn-ghost">
<span class="notify-icon">🔔</span> Notify Me
</button>
</div> </div>
</header> </header>
@ -175,6 +224,7 @@
</div> </div>
</main> </main>
</div> </div>
<div id="toast-container"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,21 @@
{
"name": "Antigravity Agency Dashboard",
"short_name": "Agency",
"description": "Autonomous AI Agent Dashboard",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0c",
"theme_color": "#5d5dff",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

119
frontend/public/sw.js Normal file
View file

@ -0,0 +1,119 @@
const CACHE_NAME = 'agency-cache-v4';
const ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/icon-192.png',
'/icon-512.png'
];
// Force immediate update to the latest SW
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
}).then(() => self.skipWaiting())
);
});
// Clean up old caches and take control of all clients immediately
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
// Only intercept http/https requests
if (!event.request.url.startsWith('http')) return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).catch((error) => {
// If network fetch fails and it's a navigation request, return index.html
if (event.request.mode === 'navigate') {
return caches.match('/index.html');
}
// For assets, return a failure response instead of throwing.
// Re-throwing (or returning a rejected promise) causes the browser to show
// the "unexpected error" interception UI.
console.warn('Fetch failed for:', event.request.url, error);
return new Response('Network error occurred', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({ 'Content-Type': 'text/plain' })
});
});
})
);
});
self.addEventListener('push', (event) => {
let data = { title: 'Notification', body: 'New update from Agency' };
try {
if (event.data) {
data = event.data.json();
}
} catch (e) {
console.error('Error parsing push data:', e);
}
const options = {
body: data.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '1',
taskId: data.task_id,
runId: data.run_id
}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const taskId = event.notification.data.taskId;
const runId = event.notification.data.runId;
let url = '/';
if (taskId && runId) {
url = `/?taskId=${taskId}&runId=${runId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Check if there is already a window open and focus it, or open a new one
for (let client of windowClients) {
if ('focus' in client) {
// Navigate the existing client to the new URL if it's the same app
return client.navigate(url).then(c => c.focus());
}
}
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});

View file

@ -1,5 +1,6 @@
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import './style.css';
const API_URL = '/api'; const API_URL = '/api';
// These should ideally be environment-specific // These should ideally be environment-specific
@ -16,10 +17,11 @@ const state = {
selectedRunId: null, selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task' currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false, isEditing: false,
token: localStorage.getItem('auth_token'), isAuthenticated: false,
refreshToken: localStorage.getItem('refresh_token') chatMessages: [],
activeDashboardTab: 'chat', // 'chat' or 'activity'
swRegistration: null
}; };
// DOM elements // DOM elements
const loginOverlay = document.getElementById('login-overlay'); const loginOverlay = document.getElementById('login-overlay');
const callbackOverlay = document.getElementById('callback-overlay'); const callbackOverlay = document.getElementById('callback-overlay');
@ -55,32 +57,71 @@ const schedulePresets = document.getElementById('schedule-presets');
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron'); const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
const customCronContainer = document.getElementById('custom-cron-container'); const customCronContainer = document.getElementById('custom-cron-container');
const presetBtns = document.querySelectorAll('.btn-preset'); const presetBtns = document.querySelectorAll('.btn-preset');
const chatMessagesEl = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const clearChatBtn = document.getElementById('clear-chat-btn');
const chatSendBtn = document.getElementById('chat-send-btn');
const sidebarEl = document.querySelector('.sidebar');
const menuToggle = document.getElementById('menu-toggle');
const sidebarOverlay = document.getElementById('sidebar-overlay');
function updateState(newState) {
Object.assign(state, newState);
renderApp();
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
// Auto remove
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s forwards';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function renderApp() {
renderTaskList();
if (state.currentView === 'dashboard') {
fetchRecentRuns();
renderChat();
} else if (state.selectedTaskId) {
const task = state.tasks.find(t => t.id === state.selectedTaskId);
if (task) {
renderRunHistory(task);
showTaskView(task);
}
}
}
// Wrapper for fetch to include Authorization header // Wrapper for fetch to include Authorization header
async function fetchWithAuth(url, options = {}) { async function fetchWithAuth(url, options = {}) {
if (!state.token) { let response = await fetch(url, { ...options, credentials: 'include' });
showLogin();
throw new Error('Not authenticated');
}
const headers = { if (response.status === 401) {
...options.headers,
'Authorization': `Bearer ${state.token}`
};
let response = await fetch(url, { ...options, headers });
if (response.status === 401 && state.refreshToken) {
// Try to refresh token // Try to refresh token
try { try {
const success = await attemptTokenRefresh(); const success = await attemptTokenRefresh();
if (success) { if (success) {
// Retry original request with new token // Retry original request
const newHeaders = { response = await fetch(url, { ...options, credentials: 'include' });
...options.headers,
'Authorization': `Bearer ${state.token}`
};
response = await fetch(url, { ...options, headers: newHeaders });
} }
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
@ -100,65 +141,59 @@ async function attemptTokenRefresh() {
const response = await fetch(`${API_URL}/auth/refresh`, { const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: state.refreshToken }) credentials: 'include',
body: JSON.stringify({ refresh_token: '' })
}); });
if (response.ok) { if (response.ok) {
const data = await response.json();
if (data.access_token) {
state.token = data.access_token;
localStorage.setItem('auth_token', data.access_token);
if (data.refresh_token) {
state.refreshToken = data.refresh_token;
localStorage.setItem('refresh_token', data.refresh_token);
}
return true; return true;
} }
}
} catch (error) { } catch (error) {
console.error('Error during token refresh:', error); console.error('Error during token refresh:', error);
} }
return false; return false;
} }
// I'll replace the fetchTasks function and add updateState
async function fetchTasks() { async function fetchTasks() {
try { try {
const response = await fetchWithAuth(`${API_URL}/tasks`); const response = await fetchWithAuth(`${API_URL}/tasks`);
const newTasks = await response.json(); const newTasks = await response.json();
// Check if we should follow the latest run (if we were already watching it) // Check for deep link in URL
let shouldFollowLatest = false; const params = new URLSearchParams(window.location.search);
const urlTaskId = params.get('taskId');
const urlRunId = params.get('runId');
if (urlTaskId && !state.selectedTaskId) {
state.selectedTaskId = urlTaskId;
state.selectedRunId = urlRunId;
}
// Check if we should follow the latest run
let newSelectedRunId = state.selectedRunId;
if (state.selectedTaskId) { if (state.selectedTaskId) {
const currentTask = state.tasks.find(t => t.id === state.selectedTaskId); const currentTask = newTasks.find(t => t.id === state.selectedTaskId);
if (currentTask && currentTask.runs && currentTask.runs.length > 0) { if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
const latestRunId = currentTask.runs[currentTask.runs.length - 1].id; // If we don't have a selected run or the runs changed, we might want to update
if (state.selectedRunId === latestRunId) { if (!state.selectedRunId || (state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.length !== currentTask.runs.length)) {
shouldFollowLatest = true; // Only auto-switch if we are "following" the latest
const wasFollowingLatest = state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.[0]?.id === state.selectedRunId;
if (wasFollowingLatest || !state.selectedRunId) {
newSelectedRunId = currentTask.runs[0].id;
}
} }
} else if (!state.selectedRunId) {
shouldFollowLatest = true;
} }
} }
state.tasks = newTasks; updateState({
renderTaskList(); tasks: newTasks,
selectedRunId: newSelectedRunId
});
// If we are on the dashboard, refresh it too // If we just loaded from a deep link, clear the params and select it
if (state.currentView === 'dashboard') { if (urlTaskId) {
fetchRecentRuns(); window.history.replaceState({}, document.title, "/");
} selectTask(urlTaskId, urlRunId);
// If a task is selected, update it
if (state.selectedTaskId) {
const task = state.tasks.find((t) => t.id === state.selectedTaskId);
if (task) {
if (shouldFollowLatest && task.runs && task.runs.length > 0) {
state.selectedRunId = task.runs[task.runs.length - 1].id;
}
renderRunHistory(task);
showTaskView(task);
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching tasks:', error); console.error('Error fetching tasks:', error);
@ -177,13 +212,7 @@ async function fetchRecentRuns() {
function renderTaskList() { function renderTaskList() {
const sortedTasks = [...state.tasks].sort((a, b) => { const sortedTasks = [...state.tasks].sort((a, b) => {
const aDate = a.runs && a.runs.length > 0 return new Date(b.created_at) - new Date(a.created_at);
? new Date(a.runs[0].created_at)
: new Date(a.created_at);
const bDate = b.runs && b.runs.length > 0
? new Date(b.runs[0].created_at)
: new Date(b.created_at);
return bDate - aDate;
}); });
taskListEl.innerHTML = sortedTasks taskListEl.innerHTML = sortedTasks
@ -223,8 +252,8 @@ function selectTask(id, runId = null) {
if (runId) { if (runId) {
state.selectedRunId = runId; state.selectedRunId = runId;
} else if (task.runs && task.runs.length > 0) { } else if (task.runs && task.runs.length > 0) {
// Default to latest run if not specified // Default to latest run if not specified (index 0 is newest)
state.selectedRunId = task.runs[task.runs.length - 1].id; state.selectedRunId = task.runs[0].id;
} else { } else {
state.selectedRunId = null; state.selectedRunId = null;
} }
@ -232,6 +261,24 @@ function selectTask(id, runId = null) {
renderTaskList(); renderTaskList();
renderRunHistory(task); renderRunHistory(task);
showTaskView(task); showTaskView(task);
// Fetch and update subscription status
fetch(`${API_URL}/tasks/${id}/subscription`)
.then(res => res.json())
.then(data => {
const btn = document.getElementById('notify-task-btn');
if (data.isSubscribed) {
btn.classList.add('notified');
} else {
btn.classList.remove('notified');
}
})
.catch(err => console.error('Failed to fetch subscription status', err));
// Close sidebar on mobile after selection
if (window.innerWidth <= 768) {
closeMobileMenu();
}
} }
function renderRunHistory(task) { function renderRunHistory(task) {
@ -264,6 +311,28 @@ function showDashboard() {
renderTaskList(); renderTaskList();
fetchRecentRuns(); fetchRecentRuns();
renderDashboardTabs();
}
function renderDashboardTabs() {
const tabs = document.querySelectorAll('.tab-btn');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
if (tab.dataset.tab === state.activeDashboardTab) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
panels.forEach(panel => {
if (panel.id === `${state.activeDashboardTab}-tab-panel`) {
panel.classList.add('active');
} else {
panel.classList.remove('active');
}
});
} }
function renderDashboard(recentRuns) { function renderDashboard(recentRuns) {
@ -354,6 +423,62 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function renderChat() {
if (!chatMessagesEl) return;
if (state.chatMessages.length === 0) {
chatMessagesEl.innerHTML = `
<div class="chat-empty-state">
<p>Start a conversation with the assistant.</p>
</div>
`;
return;
}
chatMessagesEl.innerHTML = state.chatMessages
.map(msg => `
<div class="chat-message ${msg.role}">
${DOMPurify.sanitize(marked.parse(msg.content || ''))}
</div>
`)
.join('');
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
}
async function sendChatMessage(text) {
const userMessage = { role: 'user', content: text };
state.chatMessages.push(userMessage);
renderChat();
chatInput.value = '';
chatInput.disabled = true;
chatSendBtn.disabled = true;
try {
const response = await fetchWithAuth(`${API_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: state.chatMessages })
});
if (!response.ok) throw new Error('Chat API failed');
const result = await response.json();
state.chatMessages.push(result.message);
renderChat();
} catch (error) {
console.error('Chat error:', error);
showToast('Failed to get chat response.', 'error');
state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
renderChat();
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
}
// Event Listeners // Event Listeners
rerunBtn.addEventListener('click', async () => { rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return; if (!state.selectedTaskId) return;
@ -368,9 +493,10 @@ rerunBtn.addEventListener('click', async () => {
state.tasks[index] = updatedTask; state.tasks[index] = updatedTask;
} }
selectTask(updatedTask.id); selectTask(updatedTask.id);
showToast('Task rerun successfully!', 'success');
} catch (error) { } catch (error) {
console.error('Error running task:', error); console.error('Failed to rerun task:', error);
alert('Failed to run task.'); showToast('Failed to rerun task.', 'error');
} }
}); });
@ -446,6 +572,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
customCronContainer.classList.toggle('hidden'); customCronContainer.classList.toggle('hidden');
}); });
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (text) {
sendChatMessage(text);
}
});
clearChatBtn.addEventListener('click', () => {
state.chatMessages = [];
renderChat();
});
cronInput.addEventListener('input', () => { cronInput.addEventListener('input', () => {
// If user types manually, update presets active state // If user types manually, update presets active state
updateScheduleUI(cronInput.value); updateScheduleUI(cronInput.value);
@ -489,23 +628,107 @@ newTaskForm.addEventListener('submit', async (e) => {
state.isEditing = false; state.isEditing = false;
selectTask(updatedTask.id); selectTask(updatedTask.id);
renderTaskList(); renderTaskList();
showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
} catch (error) { } catch (error) {
console.error('Error creating task:', error); console.error('Save task failed:', error);
alert('Failed to execute task. Check console.'); showToast('Failed to execute task. Check console.', 'error');
} }
}); });
let isPolling = false; async function checkSession() {
async function startAutoRefresh() {
setInterval(async () => {
if (isPolling) return;
isPolling = true;
try { try {
await fetchTasks(); const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' });
} finally { if (response.ok) {
isPolling = false; state.isAuthenticated = true;
return true;
} }
}, 3000); } catch (error) {
console.error('Session check failed:', error);
}
state.isAuthenticated = false;
return false;
}
let socket = null;
let reconnectDelay = 1000;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
console.log('Connecting to WebSocket:', wsUrl);
socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('WebSocket connected');
reconnectDelay = 1000;
// Initial fetch to sync state
fetchTasks();
};
socket.onmessage = (event) => {
try {
const { type, data } = JSON.parse(event.data);
console.log('WebSocket event:', type, data);
switch (type) {
case 'TaskCreated':
state.tasks.unshift(data);
renderApp();
showToast('New task created', 'success');
break;
case 'TaskUpdated':
case 'RunFinished':
const index = state.tasks.findIndex(t => t.id === data.id);
if (index !== -1) {
const wasSelected = state.selectedTaskId === data.id;
state.tasks[index] = data;
if (wasSelected) {
// Update selected run if we were following latest
const wasFollowingLatest = state.selectedRunId === state.tasks[index].runs?.[1]?.id || !state.selectedRunId;
if (wasFollowingLatest && data.runs && data.runs.length > 0) {
state.selectedRunId = data.runs[0].id;
}
}
} else {
state.tasks.unshift(data);
}
renderApp();
if (type === 'RunFinished') {
showToast(`Task run completed: ${data.goal}`, 'info');
}
break;
case 'RunStarted':
const taskIndex = state.tasks.findIndex(t => t.id === data.task_id);
if (taskIndex !== -1) {
// We don't have the full task update here, but we can update status
// For simplicity, we just trigger a fetch or wait for RunFinished
// But let's at least show it's running in the UI if selected
if (state.tasks[taskIndex].runs) {
// Prepend a dummy run or just fetch
fetchTasks();
}
}
showToast(`Task started: ${data.goal}`, 'info');
break;
}
} catch (e) {
console.error('Error handling WebSocket message:', e);
}
};
socket.onclose = () => {
console.log('WebSocket disconnected. Reconnecting...');
setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
connectWebSocket();
}, reconnectDelay);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
socket.close();
};
} }
async function showLogin() { async function showLogin() {
@ -514,10 +737,12 @@ async function showLogin() {
} }
async function logout() { async function logout() {
state.token = null; try {
state.refreshToken = null; await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
localStorage.removeItem('auth_token'); } catch (error) {
localStorage.removeItem('refresh_token'); console.error('Logout failed:', error);
}
state.isAuthenticated = false;
showLogin(); showLogin();
} }
@ -531,16 +756,20 @@ async function handleCallback() {
loginOverlay.classList.add('hidden'); loginOverlay.classList.add('hidden');
try { try {
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`); const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, {
const data = await response.json(); credentials: 'include'
});
if (data.access_token) { const contentType = response.headers.get("content-type");
state.token = data.access_token; let data;
localStorage.setItem('auth_token', data.access_token); if (contentType && contentType.includes("application/json")) {
if (data.refresh_token) { data = await response.json();
state.refreshToken = data.refresh_token; } else {
localStorage.setItem('refresh_token', data.refresh_token); const text = await response.text();
throw new Error(`Expected JSON but got ${contentType}. Body: ${text.substring(0, 100)}`);
} }
if (response.ok) {
state.isAuthenticated = true;
callbackOverlay.classList.add('hidden'); callbackOverlay.classList.add('hidden');
appEl.classList.remove('hidden'); appEl.classList.remove('hidden');
initializeApp(); initializeApp();
@ -548,14 +777,14 @@ async function handleCallback() {
throw new Error('No access token in response'); throw new Error('No access token in response');
} }
} catch (error) { } catch (error) {
console.error('Auth callback failed:', error); console.error('Callback failed:', error);
alert('Authentication failed.'); showToast('Authentication failed.', 'error');
showLogin(); showLogin();
} }
} }
loginBtn.addEventListener('click', () => { loginBtn.addEventListener('click', () => {
const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email`; const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email offline_access`;
window.location.href = authUrl; window.location.href = authUrl;
}); });
@ -563,17 +792,51 @@ logoutBtn.addEventListener('click', () => {
logout(); logout();
}); });
async function initializeApp() { document.querySelectorAll('.tab-btn').forEach(btn => {
if (!state.token) { btn.addEventListener('click', () => {
showLogin(); state.activeDashboardTab = btn.dataset.tab;
return; renderDashboardTabs();
});
});
function toggleMobileMenu() {
sidebarEl.classList.toggle('open');
menuToggle.classList.toggle('active');
sidebarOverlay.classList.toggle('hidden');
document.body.style.overflow = sidebarEl.classList.contains('open') ? 'hidden' : '';
} }
function closeMobileMenu() {
sidebarEl.classList.remove('open');
menuToggle.classList.remove('active');
sidebarOverlay.classList.add('hidden');
document.body.style.overflow = '';
}
menuToggle.addEventListener('click', toggleMobileMenu);
sidebarOverlay.addEventListener('click', closeMobileMenu);
async function initializeApp() {
const hasSession = await checkSession();
if (hasSession) {
appEl.classList.remove('hidden'); appEl.classList.remove('hidden');
loginOverlay.classList.add('hidden'); loginOverlay.classList.add('hidden');
// Handle deep links from notifications
const params = new URLSearchParams(window.location.search);
const taskId = params.get('taskId');
const runId = params.get('runId');
if (taskId) {
state.selectedTaskId = taskId;
state.selectedRunId = runId;
}
await fetchTasks(); await fetchTasks();
startAutoRefresh(); connectWebSocket();
} else {
showLogin();
}
} }
// Check for callback on load // Check for callback on load
@ -582,3 +845,116 @@ if (window.location.pathname === '/callback' || window.location.search.includes(
} else { } else {
initializeApp(); initializeApp();
} }
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('SW registered', reg);
state.swRegistration = reg;
})
.catch(err => {
console.error('SW registration failed:', err);
if (window.isSecureContext === false) {
console.error('Context is NOT secure. Service Workers require HTTPS or localhost.');
}
});
});
}
async function setupPush() {
if (!state.swRegistration) {
console.warn('SW registration not available');
return false;
}
try {
const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`);
const { publicKey } = await vapidResponse.json();
// Always clear existing subscription to ensure we use latest VAPID key
const existingSub = await state.swRegistration.pushManager.getSubscription();
if (existingSub) {
await existingSub.unsubscribe();
console.log('Unsubscribed existing push subscription');
}
const subscription = await state.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
await fetch(`${API_URL}/notifications/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
p256dh: b64(subscription.getKey('p256dh')),
auth: b64(subscription.getKey('auth'))
})
});
console.log('Push registered');
return true;
} catch (err) {
console.warn('Push registration failed:', err);
return false;
}
}
function b64(buffer) {
const binary = String.fromCharCode.apply(null, new Uint8Array(buffer));
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function toggleTaskSubscription(taskId) {
const btn = document.getElementById('notify-task-btn');
const isNotified = btn.classList.contains('notified');
const method = isNotified ? 'DELETE' : 'POST';
// If trying to enable but no push subscription, try setting it up first (user gesture here)
if (!isNotified && 'Notification' in window) {
if (Notification.permission !== 'granted') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
showToast('Notification permission denied', 'error');
return;
}
}
const sub = await state.swRegistration.pushManager.getSubscription();
if (!sub) {
const success = await setupPush();
if (!success) {
showToast('Failed to initialize push notifications', 'error');
return;
}
}
}
try {
await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method });
btn.classList.toggle('notified');
showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled');
} catch (err) {
showToast('Failed to update notifications');
}
}
document.getElementById('notify-task-btn').addEventListener('click', () => {
if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId);
});

View file

@ -25,6 +25,7 @@ body {
color: var(--text-main); color: var(--text-main);
line-height: 1.5; line-height: 1.5;
height: 100vh; height: 100vh;
height: 100dvh;
overflow: hidden; overflow: hidden;
} }
@ -88,9 +89,15 @@ body {
.dashboard-content { .dashboard-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
border-radius: 12px; border-radius: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
max-width: 900px;
background: rgba(255, 255, 255, 0.02);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
border: 1px solid var(--glass-border);
min-height: 0;
} }
.activity-table { .activity-table {
@ -124,6 +131,158 @@ body {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.dashboard-content-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
flex: 1;
min-height: 0;
width: 100%;
}
.dashboard-tabs {
display: flex;
gap: 12px;
margin-top: 24px;
}
.tab-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
color: var(--text-dim);
padding: 10px 20px;
border-radius: 100px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 8px;
}
.tab-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
}
.tab-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
box-shadow: 0 4px 15px var(--primary-glow);
}
.tab-panel {
display: none;
width: 100%;
flex-direction: column;
align-items: center;
flex: 1;
min-height: 0;
}
.tab-panel.active {
display: flex;
}
.chat-container {
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
width: 100%;
max-width: 900px;
flex: 1;
min-height: 0;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
border: 1px solid var(--glass-border);
}
.chat-header {
padding: 16px 20px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--glass-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-empty-state {
margin: auto;
text-align: center;
color: var(--text-dim);
font-style: italic;
font-size: 13px;
opacity: 0.6;
}
.chat-message {
max-width: 80%;
padding: 12px 18px;
border-radius: 16px;
font-size: 15px;
line-height: 1.5;
word-wrap: break-word;
}
.chat-message.user {
align-self: flex-end;
background: var(--primary);
color: white;
border-bottom-right-radius: 4px;
}
.chat-message.assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border-bottom-left-radius: 4px;
}
.chat-form {
padding: 16px;
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid var(--glass-border);
display: flex;
gap: 10px;
}
.chat-form input {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 12px 20px;
color: var(--text-main);
font-size: 15px;
outline: none;
transition: var(--transition);
}
.chat-form input:focus {
border-color: var(--primary);
}
.activity-table .status-badge { .activity-table .status-badge {
display: inline-block; display: inline-block;
} }
@ -796,8 +955,79 @@ textarea:focus {
} }
.btn-sm { .btn-sm {
padding: 6px 12px; padding: 6px 16px;
font-size: 12px; font-size: 12px;
width: auto;
justify-content: center;
}
/* Toast System */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2000;
}
.toast {
min-width: 300px;
padding: 16px 20px;
border-radius: 12px;
background: var(--bg-sidebar);
border: 1px solid var(--glass-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.error {
border-left: 4px solid var(--status-failed);
}
.toast.success {
border-left: 4px solid var(--status-completed);
}
.toast.info {
border-left: 4px solid var(--primary);
}
.toast-icon {
font-size: 18px;
}
.toast-message {
font-size: 14px;
font-weight: 500;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
} }
.logout-btn { .logout-btn {
@ -815,3 +1045,241 @@ textarea:focus {
.logout-btn:hover { .logout-btn:hover {
color: var(--primary); color: var(--primary);
} }
/* Mobile Menu Toggle */
.menu-toggle {
display: none;
position: fixed;
top: calc(16px + env(safe-area-inset-top));
right: calc(16px + env(safe-area-inset-right));
z-index: 1100;
background: var(--primary);
border: none;
width: 44px;
height: 44px;
border-radius: 10px;
cursor: pointer;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
box-shadow: 0 4px 15px var(--primary-glow);
transition: var(--transition);
}
.menu-toggle .bar {
display: block;
width: 20px;
height: 2px;
background: white;
border-radius: 2px;
transition: var(--transition);
}
.menu-toggle.active .bar:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.menu-toggle.active .bar:nth-child(2) {
opacity: 0;
}
.menu-toggle.active .bar:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
transition: opacity 0.3s ease;
}
/* Responsive Styles */
#notify-task-btn.notified {
color: var(--primary);
background: var(--primary-glow);
}
#notify-task-btn.notified .notify-icon {
animation: ring 0.5s ease;
}
@keyframes ring {
0% {
transform: rotate(0);
}
25% {
transform: rotate(15deg);
}
50% {
transform: rotate(-15deg);
}
75% {
transform: rotate(10deg);
}
100% {
transform: rotate(0);
}
}
@media (max-width: 768px) {
.menu-toggle {
display: flex;
}
.sidebar {
position: fixed;
left: -320px;
top: 0;
height: 100%;
z-index: 1050;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
.sidebar.open {
left: 0;
}
.dashboard-view,
.task-view {
padding: 20px;
padding-top: calc(80px + env(safe-area-inset-top));
padding-left: max(20px, env(safe-area-inset-left));
padding-right: max(20px, env(safe-area-inset-right));
padding-bottom: max(20px, env(safe-area-inset-bottom));
height: auto;
min-height: 100%;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
.dashboard-header h2 {
font-size: 24px;
}
.task-content {
flex-direction: column;
height: auto;
overflow: visible;
width: 100%;
gap: 16px;
}
.run-history {
width: 100%;
max-height: 200px;
flex-shrink: 0;
margin-bottom: 8px;
}
.run-details {
flex-direction: column;
height: auto;
overflow: visible;
gap: 16px;
width: 100%;
}
.answer-container {
width: 100%;
margin-bottom: 8px;
border-left: none;
border-top: 4px solid var(--primary);
}
.logs-container {
width: 100%;
height: 400px;
flex-shrink: 0;
}
.view-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-main {
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.header-main h2 {
font-size: 20px;
line-height: 1.3;
}
.header-actions {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.header-actions .btn {
flex: 1 1 auto;
justify-content: center;
}
.modal {
width: 95%;
padding: 20px;
max-height: 90%;
overflow-y: auto;
}
.preset-group {
grid-template-columns: 1fr;
}
.chat-container {
height: 500px;
}
.chat-form input,
textarea {
font-size: 16px !important;
}
/* Force word break for long text in containers */
.answer-output,
.activity-table td {
word-break: break-word;
}
.activity-table td {
padding: 12px 10px;
}
/* Hide Date on very small mobile to prevent table overflow */
@media (max-width: 480px) {
.activity-table th:last-child,
.activity-table td:last-child {
display: none;
}
}
/* Prevent horizontal scroll on the entire app */
#app {
width: 100vw;
overflow-x: hidden;
}
}

View file

@ -4,8 +4,9 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
ws: true,
} }
} }
} }

View file

@ -4,6 +4,7 @@ mod m20220101_000001_create_table;
mod m20260210_000002_add_answer_column; mod m20260210_000002_add_answer_column;
mod m20260210_000003_separate_runs; mod m20260210_000003_separate_runs;
mod m20260210_000004_add_cron_column; mod m20260210_000004_add_cron_column;
mod m20260212_000005_notifications;
pub struct Migrator; pub struct Migrator;
@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
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_000003_separate_runs::Migration),
Box::new(m20260210_000004_add_cron_column::Migration), Box::new(m20260210_000004_add_cron_column::Migration),
Box::new(m20260212_000005_notifications::Migration),
] ]
} }
} }

View file

@ -0,0 +1,115 @@
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> {
// Push Subscriptions table
manager
.create_table(
Table::create()
.table(PushSubscriptions::Table)
.if_not_exists()
.col(
ColumnDef::new(PushSubscriptions::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(PushSubscriptions::UserSub)
.string()
.not_null(),
)
.col(
ColumnDef::new(PushSubscriptions::Endpoint)
.string()
.not_null(),
)
.col(
ColumnDef::new(PushSubscriptions::P256dh)
.string()
.not_null(),
)
.col(ColumnDef::new(PushSubscriptions::Auth).string().not_null())
.col(
ColumnDef::new(PushSubscriptions::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
// Task Subscriptions table
manager
.create_table(
Table::create()
.table(TaskSubscriptions::Table)
.if_not_exists()
.col(
ColumnDef::new(TaskSubscriptions::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(TaskSubscriptions::UserSub)
.string()
.not_null(),
)
.col(ColumnDef::new(TaskSubscriptions::TaskId).uuid().not_null())
.col(
ColumnDef::new(TaskSubscriptions::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk-task-subscription-task-id")
.from(TaskSubscriptions::Table, TaskSubscriptions::TaskId)
.to(Tasks::Table, Tasks::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(TaskSubscriptions::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(PushSubscriptions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum PushSubscriptions {
Table,
Id,
UserSub,
Endpoint,
P256dh,
Auth,
CreatedAt,
}
#[derive(DeriveIden)]
enum TaskSubscriptions {
Table,
Id,
UserSub,
TaskId,
CreatedAt,
}
#[derive(DeriveIden)]
enum Tasks {
Table,
Id,
}

View file

@ -0,0 +1,593 @@
{
"openapi": "3.1.0",
"info": {
"title": "calendar",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {
"/auth/me": {
"get": {
"tags": [
"crate::handlers::auth"
],
"operationId": "me",
"responses": {
"200": {
"description": "Current user profile",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentUser"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"oidc": []
}
]
}
},
"/events": {
"get": {
"tags": [
"crate::handlers::event"
],
"operationId": "list_events",
"parameters": [
{
"name": "upcoming",
"in": "query",
"required": false,
"schema": {
"type": [
"boolean",
"null"
]
}
}
],
"responses": {
"200": {
"description": "List of events",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Model"
}
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"oidc": []
}
]
},
"post": {
"tags": [
"crate::handlers::event"
],
"operationId": "create_event",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateEventRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Event created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"400": {
"description": "Invalid request payload"
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"oidc": []
}
]
}
},
"/events/{id}": {
"get": {
"tags": [
"crate::handlers::event"
],
"operationId": "get_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Event details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
},
"put": {
"tags": [
"crate::handlers::event"
],
"operationId": "update_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateEventRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Event updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"400": {
"description": "Invalid request payload"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
},
"delete": {
"tags": [
"crate::handlers::event"
],
"operationId": "delete_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "Event deleted successfully"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
}
},
"/service/v1/events": {
"get": {
"tags": [
"crate::handlers::service"
],
"operationId": "service_list_events",
"parameters": [
{
"name": "user_id",
"in": "query",
"required": false,
"schema": {
"type": [
"integer",
"null"
],
"format": "int32"
}
},
{
"name": "upcoming",
"in": "query",
"required": false,
"schema": {
"type": [
"boolean",
"null"
]
}
}
],
"responses": {
"200": {
"description": "List of events",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Model"
}
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"oidc": []
}
]
},
"post": {
"tags": [
"crate::handlers::service"
],
"operationId": "service_create_event",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceCreateEventRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Event created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"400": {
"description": "Invalid request payload"
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"oidc": []
}
]
}
},
"/service/v1/events/{id}": {
"get": {
"tags": [
"crate::handlers::service"
],
"operationId": "service_get_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Event details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
},
"put": {
"tags": [
"crate::handlers::service"
],
"operationId": "service_update_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceCreateEventRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Event updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Model"
}
}
}
},
"400": {
"description": "Invalid request payload"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
},
"delete": {
"tags": [
"crate::handlers::service"
],
"operationId": "service_delete_event",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Event database id",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "Event deleted successfully"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Event not found"
}
},
"security": [
{
"oidc": []
}
]
}
}
},
"components": {
"schemas": {
"CreateEventRequest": {
"type": "object",
"required": [
"name",
"from",
"to"
],
"properties": {
"from": {
"type": "string"
},
"name": {
"type": "string"
},
"to": {
"type": "string"
}
}
},
"CurrentUser": {
"type": "object",
"required": [
"id",
"sub",
"email",
"name"
],
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
},
"sub": {
"type": "string"
}
}
},
"Model": {
"type": "object",
"required": [
"id",
"name",
"from",
"to"
],
"properties": {
"from": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"to": {
"type": "string",
"format": "date-time"
},
"user_id": {
"type": [
"integer",
"null"
],
"format": "int32"
}
}
},
"ServiceCreateEventRequest": {
"type": "object",
"required": [
"name",
"from",
"to"
],
"properties": {
"from": {
"type": "string"
},
"name": {
"type": "string"
},
"to": {
"type": "string"
},
"user_id": {
"type": [
"integer",
"null"
],
"format": "int32"
}
}
}
}
},
"tags": [
{
"name": "calendar",
"description": "Calendar Management API"
}
]
}

View file

@ -1,146 +0,0 @@
use chrono::Utc;
use crate::api::{ChatRequest, ChatResponse, Message, Tool};
use crate::tools;
pub struct Agent {
client: reqwest::Client,
url: String,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
messages: Vec<Message>,
tools: Option<Vec<Tool>>,
logs: String,
answer: Option<String>,
}
impl Agent {
pub fn new(
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
initial_message: String,
) -> Result<Self, Box<dyn std::error::Error>> {
let intro = format!(
"You are an autonomous agent. You have access to tools that can help
you achieve your goals. Use them wisely. The user is unable to respond to you
so do not ask for clarification and use the
answer tool once you to give your final answer. current date is {}",
Utc::now().format("%B %d, %Y %H:%M:%S UTC").to_string()
);
println!("initial_message: {}", intro);
let messages = vec![
Message {
role: "system".to_string(),
content: Some(intro),
tool_calls: None,
tool_call_id: None,
},
Message {
role: "user".to_string(),
content: Some(initial_message),
tool_calls: None,
tool_call_id: None,
},
];
let tools = Some(tools::get_tools());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
Ok(Self {
client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key,
tavily_api_key,
messages,
tools,
logs: String::new(),
answer: None,
})
}
fn log(&mut self, message: &str) {
println!("{}", message);
self.logs.push_str(message);
self.logs.push('\n');
}
pub async fn run(&mut self) -> Result<(String, Option<String>), Box<dyn std::error::Error>> {
let mut file_written = false;
while !file_written {
let request = ChatRequest {
model: "kimi-k2.5".to_string(),
messages: self.messages.clone(),
tools: self.tools.clone(),
};
self.log(&format!(
"--- Sending request to Zen API (Role: {}) ---",
self.messages.last().unwrap().role
));
let mut request_builder = self.client.post(&self.url).json(&request);
if let Some(key) = &self.zen_api_key {
request_builder =
request_builder.header("Authorization", format!("Bearer {}", key));
}
let response = request_builder.send().await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await?;
self.log(&format!("Error: API request failed with status {}", status));
self.log(&format!("Error details: {}", error_text));
return Err(format!("API request failed: {}", status).into());
}
let chat_response: ChatResponse = response.json().await?;
let assistant_message = chat_response.choices.get(0).unwrap().message.clone();
self.messages.push(assistant_message.clone());
if let Some(content) = &assistant_message.content {
if !content.is_empty() {
self.log(&format!("\nAssistant response:\n{}\n", content));
}
}
if let Some(tool_calls) = assistant_message.tool_calls {
for tool_call in tool_calls {
let (tool_message, written, tool_answer) =
tools::handle_tool_call(&tool_call, &self.tavily_api_key).await?;
if let Some(ans) = tool_answer {
self.answer = Some(ans);
}
if let Some(content) = &tool_message.content {
self.log(&format!(
"Tool result ({}): {}",
tool_call.function.name, content
));
}
self.messages.push(tool_message);
if written {
file_written = true;
}
}
// Continue the loop to send tool results back
continue;
}
// No more tool calls from assistant, but we only exit if file was written
if !file_written {
self.log("--- Assistant didn't use write_file yet. Waiting for next turn... ---");
}
}
Ok((self.logs.clone(), self.answer.clone()))
}
}

81
src/config.rs Normal file
View file

@ -0,0 +1,81 @@
use crate::error::{AppError, AppResult};
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub port: u16,
pub zen_api_key: Option<String>,
pub tavily_api_key: Option<String>,
pub authentik_issuer: String,
pub authentik_client_id: String,
pub authentik_client_secret: String,
pub cors_allowed_origins: Option<String>,
pub cookie_secure: bool,
pub agent_max_turns: u32,
pub agent_max_duration_secs: u64,
pub vapid_private_key: String,
pub calendar_api_url: String,
}
impl Config {
pub fn from_env() -> AppResult<Self> {
dotenvy::dotenv().ok();
let database_url = env::var("DATABASE_URL")
.map_err(|_| AppError::Config("DATABASE_URL must be set".into()))?;
let port = env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3000);
let zen_api_key = env::var("ZEN_API_KEY").ok();
let tavily_api_key = env::var("TAVILY_API_KEY").ok();
let authentik_issuer = env::var("AUTHENTIK_ISSUER")
.map_err(|_| AppError::Config("AUTHENTIK_ISSUER must be set".into()))?;
let authentik_client_id = env::var("AUTHENTIK_CLIENT_ID")
.map_err(|_| AppError::Config("AUTHENTIK_CLIENT_ID must be set".into()))?;
let authentik_client_secret = env::var("AUTHENTIK_CLIENT_SECRET")
.map_err(|_| AppError::Config("AUTHENTIK_CLIENT_SECRET must be set".into()))?;
let cors_allowed_origins = env::var("CORS_ALLOWED_ORIGINS").ok();
let cookie_secure = env::var("COOKIE_SECURE")
.map(|v| v == "true")
.unwrap_or(false);
let agent_max_turns = env::var("AGENT_MAX_TURNS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let agent_max_duration_secs = env::var("AGENT_MAX_DURATION_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(120);
let vapid_private_key = env::var("VAPID_PRIVATE_KEY")
.map_err(|_| AppError::Config("VAPID_PRIVATE_KEY must be set".into()))?;
let calendar_api_url =
env::var("CALENDAR_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
Ok(Config {
database_url,
port,
zen_api_key,
tavily_api_key,
authentik_issuer,
authentik_client_id,
authentik_client_secret,
cors_allowed_origins,
cookie_secure,
agent_max_turns,
agent_max_duration_secs,
vapid_private_key,
calendar_api_url,
})
}
}

View file

@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message { pub struct Message {
pub role: String, pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>, pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>, pub tool_calls: Option<Vec<ToolCall>>,
@ -29,6 +28,7 @@ pub struct FunctionCall {
pub struct ChatRequest { pub struct ChatRequest {
pub model: String, pub model: String,
pub messages: Vec<Message>, pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>, pub tools: Option<Vec<Tool>>,
} }
@ -72,8 +72,11 @@ pub async fn perform_search(
query: &str, query: &str,
api_key: &str, api_key: &str,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
tracing::info!(query = %query, "Performing Tavily web search");
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_idle_timeout(std::time::Duration::from_secs(60))
.build()?; .build()?;
let response = client let response = client
.post("https://api.tavily.com/search") .post("https://api.tavily.com/search")
@ -94,6 +97,7 @@ pub async fn perform_search(
} }
let search_data: TavilyResponse = response.json().await?; let search_data: TavilyResponse = response.json().await?;
tracing::info!("Web search yielded {} results", search_data.results.len());
let mut results_text = String::new(); let mut results_text = String::new();
for (i, result) in search_data.results.iter().enumerate() { for (i, result) in search_data.results.iter().enumerate() {

322
src/domain/agent/mod.rs Normal file
View file

@ -0,0 +1,322 @@
pub mod api;
pub mod tools;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use std::time::{Duration, Instant};
use self::api::{ChatRequest, ChatResponse, Message, Tool};
pub struct Agent {
db: DatabaseConnection,
client: reqwest::Client,
url: String,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
pub user_sub: Option<String>,
pub messages: Vec<Message>,
tools: Option<Vec<Tool>>,
logs: String,
answer: Option<String>,
}
use crate::error::{AppError, AppResult};
impl Agent {
pub fn new(
db: DatabaseConnection,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
user_sub: Option<String>,
initial_message: String,
) -> AppResult<Self> {
let intro = format!(
"You are an autonomous agent. You have access to tools that can help
you achieve your goals. Use them wisely. The user is unable to respond to you
so do not ask for clarification and use the
answer tool once you to give your final answer. current date is {}",
Utc::now().format("%B %d, %Y %H:%M:%S UTC").to_string()
);
let messages = vec![
Message {
role: "system".to_string(),
content: Some(intro),
tool_calls: None,
tool_call_id: None,
},
Message {
role: "user".to_string(),
content: Some(initial_message),
tool_calls: None,
tool_call_id: None,
},
];
Self::with_messages(
db,
zen_api_key,
tavily_api_key,
calendar_client,
user_sub,
messages,
)
}
pub fn with_messages(
db: DatabaseConnection,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
user_sub: Option<String>,
messages: Vec<Message>,
) -> AppResult<Self> {
let tools = Some(tools::get_tools());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.tcp_keepalive(std::time::Duration::from_secs(30))
.pool_idle_timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
Ok(Self {
db,
client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key,
tavily_api_key,
calendar_client,
user_sub,
messages,
tools,
logs: String::new(),
answer: None,
})
}
fn log(&mut self, message: &str) {
self.logs.push_str(message);
self.logs.push('\n');
}
pub async fn run(
&mut self,
config: &crate::config::Config,
) -> AppResult<(String, Option<String>)> {
let mut finished = false;
let start_time = Instant::now();
let max_duration = Duration::from_secs(config.agent_max_duration_secs);
let max_turns = config.agent_max_turns;
let mut turns = 0;
while !finished {
if start_time.elapsed() > max_duration {
return Err(AppError::Internal("Agent run timed out".into()));
}
if turns >= max_turns {
return Err(AppError::Internal("Agent run exceeded max turns".into()));
}
tracing::info!("Turn {}", turns);
turns += 1;
let current_role = self
.messages
.last()
.map(|m| m.role.as_str())
.unwrap_or("unknown");
self.log(&format!(
"\n[Turn {}] Sending request (Last role: {})",
turns, current_role
));
let assistant_message =
match tokio::time::timeout(Duration::from_secs(180), self.execute_turn()).await {
Ok(res) => res?,
Err(_) => {
tracing::error!("Agent execution turn timed out after 180s");
return Err(AppError::Internal("Agent execution turn timed out".into()));
}
};
if let Some(tool_calls) = &assistant_message.tool_calls {
tracing::info!("Assistant tool calls: {:#?}", tool_calls);
if tool_calls.iter().any(|tc| tc.function.name == "answer") {
finished = true;
}
}
if self.answer.is_some() {
tracing::info!("Answer: {}", self.answer.as_ref().unwrap());
finished = true;
}
if !finished {
self.messages.push(Message {
role: "system".to_string(),
content: Some(
"continue, use the finish tool to submit your final answer".to_string(),
),
tool_calls: None,
tool_call_id: None,
});
}
tracing::info!("Finished turn");
}
self.log("\n--- Execution Finished ---");
Ok((self.logs.clone(), self.answer.clone()))
}
pub async fn execute_turn(&mut self) -> AppResult<Message> {
let max_sub_turns = 20;
let mut sub_turns = 0;
loop {
tracing::info!("Sub turn {}", sub_turns);
sub_turns += 1;
if sub_turns > max_sub_turns {
return Err(AppError::Internal(
"Interaction cycle turn limit exceeded".into(),
));
}
let chat_response = self.call_llm().await?;
let assistant_message = chat_response
.choices
.get(0)
.ok_or_else(|| AppError::Internal("Missing assistant response".into()))?
.message
.clone();
tracing::info!("Assistant message: {:#?}", assistant_message);
self.messages.push(assistant_message.clone());
if let Some(content) = &assistant_message.content {
tracing::info!("Assistant content: {}", content);
if !content.is_empty() {
self.log(&format!("\nAssistant: {}", content));
}
}
if let Some(tool_calls) = &assistant_message.tool_calls {
tracing::info!("Assistant tool calls: {:#?}", tool_calls);
let mut is_final_cycle = false;
let mut final_answer = None;
for tool_call in tool_calls {
self.log(&format!("Calling tool: {}", tool_call.function.name));
let (tool_message, is_final, tool_answer) = tools::handle_tool_call(
tool_call,
&self.tavily_api_key,
&self.db,
&self.calendar_client,
self.user_sub.as_deref(),
)
.await
.map_err(|e| AppError::Internal(format!("Tool execution failed: {}", e)))?;
if let Some(ans) = tool_answer {
self.answer = Some(ans.clone());
final_answer = Some(ans);
self.log("Interaction marked as finished by tool.");
}
if let Some(content) = &tool_message.content {
self.log(&format!("Tool result: {}", content));
}
self.messages.push(tool_message);
if is_final {
is_final_cycle = true;
}
}
if is_final_cycle {
tracing::info!("Final answer: {}", final_answer.as_ref().unwrap());
return Ok(Message {
role: "assistant".to_string(),
content: final_answer.or(assistant_message.content),
tool_calls: None,
tool_call_id: None,
});
}
continue;
}
return Ok(assistant_message);
}
}
async fn call_llm(&self) -> AppResult<ChatResponse> {
let request = ChatRequest {
model: "kimi-k2.5".to_string(),
messages: self.messages.clone(),
tools: self.tools.clone(),
};
let mut request_builder = self
.client
.post(&self.url)
.json(&request)
.timeout(Duration::from_secs(60));
if let Some(key) = &self.zen_api_key {
request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
}
let start = std::time::Instant::now();
let response = request_builder.send().await.map_err(|e| {
let duration = start.elapsed();
let is_timeout = e.is_timeout();
let is_connect = e.is_connect();
tracing::error!(
"Network error after {:?} during LLM call (Timeout: {}, Connect: {}): {:?}",
duration,
is_timeout,
is_connect,
e
);
AppError::Network(e)
})?;
let duration = start.elapsed();
tracing::info!("LLM request completed in {:?}", duration);
if !response.status().is_success() {
let status = response.status();
let body_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown body".into());
let err = format!("API request failed: {} - {}", status, body_text);
tracing::error!("{}", err);
return Err(AppError::Internal(err));
}
let response_text = response.text().await.map_err(|e| {
let err = format!("Failed to read response text: {}", e);
tracing::error!("{}", err);
AppError::Internal(err)
})?;
serde_json::from_str(&response_text).map_err(|e| {
let err = format!(
"Failed to parse LLM response: {} | Raw Body: {}",
e, response_text
);
tracing::error!("{}", err);
AppError::Internal(err)
})
}
}

287
src/domain/agent/tools.rs Normal file
View file

@ -0,0 +1,287 @@
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Deserialize)]
struct GoogleSearchArgs {
query: String,
}
#[derive(Deserialize)]
struct FinishArgs {
result: String,
}
#[derive(Deserialize)]
struct ListRunsArgs {
from: Option<String>,
to: Option<String>,
task_ids: Option<Vec<String>>,
}
pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "google_search".to_string(),
description: "Search the web for information".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "finish".to_string(),
description: "Finish the task and provide a final answer".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The final detailed answer to the task"
}
},
"required": ["result"]
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "list_tasks".to_string(),
description: "List all existing tasks and their goals".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "list_runs".to_string(),
description: "List task runs with optional filters for date and task IDs"
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "ISO 8601 date string to filter runs from"
},
"to": {
"type": "string",
"description": "ISO 8601 date string to filter runs to"
},
"task_ids": {
"type": "array",
"items": { "type": "string", "format": "uuid" },
"description": "List of task IDs to filter runs for"
},
},
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "calendar_list_events".to_string(),
description: "List calendar events".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"upcoming": {
"type": "boolean",
"description": "If true, only upcoming events will be listed"
}
}
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "calendar_create_event".to_string(),
description: "Create a new calendar event".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the event"
},
"from": {
"type": "string",
"description": "Start time in ISO 8601 format (e.g., 2023-10-27T10:00:00Z)"
},
"to": {
"type": "string",
"description": "End time in ISO 8601 format (e.g., 2023-10-27T11:00:00Z)"
}
},
"required": ["name", "from", "to"]
}),
},
},
]
}
pub async fn handle_tool_call(
tool_call: &ToolCall,
tavily_api_key: &Option<String>,
db: &DatabaseConnection,
calendar: &Arc<crate::domain::calendar::CalendarClient>,
user_sub: Option<&str>,
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
let mut answer = None;
let name = &tool_call.function.name;
let (content, written) = if name == "google_search" {
let args: GoogleSearchArgs = serde_json::from_str(&tool_call.function.arguments)?;
let query = &args.query;
let search_result = if let Some(key) = tavily_api_key {
match api::perform_search(query, key).await {
Ok(results) => results,
Err(e) => format!("Search error: {}", e),
}
} else {
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
};
(search_result, false)
} else if name == "finish" {
let args: FinishArgs = serde_json::from_str(&tool_call.function.arguments)?;
let result = &args.result;
answer = Some(result.clone());
(result.clone(), true)
} else if name == "list_tasks" {
tracing::info!("Listing tasks from database");
use crate::entities::task;
let tasks = task::Entity::find()
.order_by_desc(task::Column::CreatedAt)
.all(db)
.await?;
let mut out = String::from("Tasks:\n");
for t in tasks {
out.push_str(&format!("- ID: {}, Goal: {}\n", t.id, t.goal));
}
(out, false)
} else if name == "list_runs" {
use crate::entities::task_run;
tracing::info!(
"Listing runs from database, args: {}",
tool_call.function.arguments
);
let args: ListRunsArgs = serde_json::from_str(&tool_call.function.arguments)?;
tracing::info!(
"Listing runs from database with filters: from={:?}, to={:?}, task_ids={:?}",
args.from,
args.to,
args.task_ids
);
let query = task_run::Entity::find();
let mut condition = Condition::all();
if let Some(from_str) = args.from {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&from_str) {
condition = condition.add(task_run::Column::CreatedAt.gte(dt));
}
}
if let Some(to_str) = args.to {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&to_str) {
condition = condition.add(task_run::Column::CreatedAt.lte(dt));
}
}
if let Some(id_strs) = args.task_ids {
let mut valid_ids = Vec::new();
for id_str in id_strs {
match id_str.parse::<Uuid>() {
Ok(uuid) => valid_ids.push(uuid),
Err(e) => tracing::warn!("Skipping invalid UUID '{}' from LLM: {}", id_str, e),
}
}
if !valid_ids.is_empty() {
condition = condition.add(task_run::Column::TaskId.is_in(valid_ids));
}
}
let runs = query
.filter(condition)
.order_by_desc(task_run::Column::CreatedAt)
.limit(20)
.all(db)
.await?;
let mut out = String::from("Recent Runs:\n");
for r in runs {
out.push_str(&format!(
"- ID: {}, Task ID: {}, Status: {}, Created At: {}, Answer: {:?}\n",
r.id, r.task_id, r.status, r.created_at, r.answer
));
}
(out, false)
} else if name == "calendar_list_events" {
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)?;
let upcoming = args["upcoming"].as_bool();
match calendar
.list_events(user_sub.map(|s| s.to_string()), upcoming)
.await
{
Ok(events) => {
tracing::info!("{:#?}", events);
(serde_json::to_string(&events)?, false)
}
Err(e) => (format!("Error listing events: {}", e), false),
}
} else if name == "calendar_create_event" {
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)?;
let name_val = args["name"].as_str().unwrap_or_default();
let from_val = args["from"].as_str().unwrap_or_default();
let to_val = args["to"].as_str().unwrap_or_default();
match calendar
.create_event(user_sub.map(|s| s.to_string()), name_val, from_val, to_val)
.await
{
Ok(event) => (
format!("Event created: {}", serde_json::to_string(&event)?),
false,
),
Err(e) => (format!("Error creating event: {}", e), false),
}
} else {
(format!("Error: Unknown tool {}", name), false)
};
Ok((
Message {
role: "tool".to_string(),
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call.id.clone()),
},
written,
answer,
))
}

View file

@ -10,10 +10,17 @@ pub struct Claims {
pub exp: usize, pub exp: usize,
pub iat: usize, pub iat: usize,
pub iss: String, pub iss: String,
pub aud: String, pub aud: Audience,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Audience {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Deserialize, Clone)]
struct Jwk { struct Jwk {
#[serde(rename = "kty")] #[serde(rename = "kty")]
_kty: String, _kty: String,
@ -31,15 +38,18 @@ struct Jwks {
pub struct JwksVerifier { pub struct JwksVerifier {
issuer: String, issuer: String,
audience: String,
jwks_uri: String, jwks_uri: String,
keys: Arc<RwLock<Vec<Jwk>>>, keys: Arc<RwLock<Vec<Jwk>>>,
client: Client, client: Client,
} }
impl JwksVerifier { impl JwksVerifier {
pub async fn new(issuer: String) -> Result<Self, Box<dyn std::error::Error>> { pub async fn new(issuer: String, audience: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new(); let client = Client::builder()
// Authentik OIDC discovery .timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
let discovery_url = format!( let discovery_url = format!(
"{}/.well-known/openid-configuration", "{}/.well-known/openid-configuration",
issuer.trim_end_matches('/') issuer.trim_end_matches('/')
@ -53,6 +63,7 @@ impl JwksVerifier {
let verifier = Self { let verifier = Self {
issuer, issuer,
audience,
jwks_uri, jwks_uri,
keys: Arc::new(RwLock::new(Vec::new())), keys: Arc::new(RwLock::new(Vec::new())),
client, client,
@ -73,18 +84,29 @@ impl JwksVerifier {
let header = decode_header(token)?; let header = decode_header(token)?;
let kid = header.kid.ok_or("Missing kid in token header")?; let kid = header.kid.ok_or("Missing kid in token header")?;
let jwk = {
let keys = self.keys.read().await; let keys = self.keys.read().await;
let jwk = keys keys.iter().find(|k| k.kid == kid).cloned()
.iter() };
let jwk = match jwk {
Some(key) => key,
None => {
self.refresh_keys().await?;
let keys = self.keys.read().await;
keys.iter()
.find(|k| k.kid == kid) .find(|k| k.kid == kid)
.ok_or("Key not found in JWKS")?; .cloned()
.ok_or("Key not found in JWKS")?
}
};
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?; let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
let mut validation = Validation::new(Algorithm::RS256); let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[self.issuer.clone()]); validation.set_issuer(&[self.issuer.clone()]);
// Aud validation might need careful config, usually it's the client_id validation.set_audience(&[self.audience.clone()]);
validation.validate_aud = false; validation.validate_aud = true;
let token_data = decode::<Claims>(token, &decoding_key, &validation)?; let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
Ok(token_data.claims) Ok(token_data.claims)
@ -104,7 +126,10 @@ impl Authenticator {
client_id: String, client_id: String,
client_secret: String, client_secret: String,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new(); let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
let discovery_url = format!( let discovery_url = format!(
"{}/.well-known/openid-configuration", "{}/.well-known/openid-configuration",
issuer.trim_end_matches('/') issuer.trim_end_matches('/')
@ -171,4 +196,27 @@ impl Authenticator {
Ok(res) Ok(res)
} }
pub async fn client_credentials(
&self,
scope: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let params = [
("grant_type", "client_credentials"),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
("scope", scope),
];
let res = self
.client
.post(&self.token_url)
.form(&params)
.send()
.await?
.json()
.await?;
Ok(res)
}
} }

298
src/domain/calendar/mod.rs Normal file
View file

@ -0,0 +1,298 @@
use crate::domain::auth::Authenticator;
use crate::error::{AppError, AppResult};
use chrono::{DateTime, Duration, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
pub id: Option<i64>,
pub name: String,
pub from: DateTime<Utc>,
pub to: DateTime<Utc>,
pub user_sub: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEventRequest {
pub name: String,
pub from: String,
pub to: String,
pub user_sub: Option<String>,
}
struct TokenState {
access_token: String,
expires_at: DateTime<Utc>,
}
pub struct CalendarClient {
base_url: String,
client: Client,
authenticator: Arc<Authenticator>,
token_state: RwLock<Option<TokenState>>,
}
impl CalendarClient {
pub fn new(base_url: String, authenticator: Arc<Authenticator>) -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url: base_url.trim_end_matches('/').to_string(),
client,
authenticator,
token_state: RwLock::new(None),
}
}
async fn get_token(&self) -> AppResult<String> {
{
let state = self.token_state.read().await;
if let Some(token) = &*state {
if token.expires_at > Utc::now() + Duration::seconds(30) {
tracing::debug!("Using cached Calendar API token");
return Ok(token.access_token.clone());
}
}
}
let mut state = self.token_state.write().await;
// Double check after acquiring write lock
if let Some(token) = &*state {
if token.expires_at > Utc::now() + Duration::seconds(30) {
return Ok(token.access_token.clone());
}
}
tracing::info!("Refreshing Calendar API token via Client Credentials flow");
let token_data = self
.authenticator
.client_credentials("profile")
.await
.map_err(|e| AppError::Internal(format!("Failed to get client credentials: {}", e)))?;
let access_token = token_data["access_token"]
.as_str()
.ok_or_else(|| AppError::Internal("Missing access_token in response".into()))?
.to_string();
let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600);
let expires_at = Utc::now() + Duration::seconds(expires_in);
*state = Some(TokenState {
access_token: access_token.clone(),
expires_at,
});
Ok(access_token)
}
pub async fn list_events(
&self,
user_sub: Option<String>,
upcoming: Option<bool>,
) -> AppResult<Vec<CalendarEvent>> {
let token = self.get_token().await?;
let mut url = format!("{}/service/v1/events", self.base_url);
let mut params = Vec::new();
if let Some(uid) = &user_sub {
params.push(format!("user_sub={}", uid));
}
if let Some(u) = upcoming {
params.push(format!("upcoming={}", u));
}
if !params.is_empty() {
url.push_str("?");
url.push_str(&params.join("&"));
}
tracing::info!(method = "GET", %url, "Sending Calendar API request");
let res = self
.client
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(AppError::Network)?;
let status = res.status();
tracing::info!(%status, %url, "Received Calendar API response");
if !status.is_success() {
let error_body = res.text().await.unwrap_or_default();
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
return Err(AppError::Internal(format!(
"Failed to list events: {} - {}",
status, error_body
)));
}
let body = res
.json()
.await
.map_err(|e| AppError::Internal(e.to_string()));
tracing::info!(%status, %url, body = ?body, "Received Calendar API response");
body
}
pub async fn create_event(
&self,
user_sub: Option<String>,
name: &str,
from: &str,
to: &str,
) -> AppResult<CalendarEvent> {
let token = self.get_token().await?;
let url = format!("{}/service/v1/events", self.base_url);
let request = CreateEventRequest {
name: name.to_string(),
from: from.to_string(),
to: to.to_string(),
user_sub,
};
tracing::info!(method = "POST", %url, "Sending Calendar API request");
let res = self
.client
.post(&url)
.bearer_auth(token)
.json(&request)
.send()
.await
.map_err(AppError::Network)?;
let status = res.status();
tracing::info!(%status, %url, "Received Calendar API response");
if !status.is_success() {
let error_body = res.text().await.unwrap_or_default();
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
return Err(AppError::Internal(format!(
"Failed to create event: {} - {}",
status, error_body
)));
}
res.json()
.await
.map_err(|e| AppError::Internal(e.to_string()))
}
#[allow(dead_code)]
pub async fn get_event(&self, id: i32) -> AppResult<CalendarEvent> {
let token = self.get_token().await?;
let url = format!("{}/service/v1/events/{}", self.base_url, id);
tracing::info!(method = "GET", %url, "Sending Calendar API request");
let res = self
.client
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(AppError::Network)?;
let status = res.status();
tracing::info!(%status, %url, "Received Calendar API response");
if !status.is_success() {
let error_body = res.text().await.unwrap_or_default();
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
return Err(AppError::Internal(format!(
"Failed to get event: {} - {}",
status, error_body
)));
}
res.json()
.await
.map_err(|e| AppError::Internal(e.to_string()))
}
#[allow(dead_code)]
pub async fn update_event(
&self,
id: i32,
user_sub: Option<String>,
name: &str,
from: &str,
to: &str,
) -> AppResult<CalendarEvent> {
let token = self.get_token().await?;
let url = format!("{}/service/v1/events/{}", self.base_url, id);
let request = CreateEventRequest {
name: name.to_string(),
from: from.to_string(),
to: to.to_string(),
user_sub,
};
tracing::info!(method = "PUT", %url, "Sending Calendar API request");
let res = self
.client
.put(&url)
.bearer_auth(token)
.json(&request)
.send()
.await
.map_err(AppError::Network)?;
let status = res.status();
tracing::info!(%status, %url, "Received Calendar API response");
if !status.is_success() {
let error_body = res.text().await.unwrap_or_default();
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
return Err(AppError::Internal(format!(
"Failed to update event: {} - {}",
status, error_body
)));
}
res.json()
.await
.map_err(|e| AppError::Internal(e.to_string()))
}
#[allow(dead_code)]
pub async fn delete_event(&self, id: i32) -> AppResult<()> {
let token = self.get_token().await?;
let url = format!("{}/service/v1/events/{}", self.base_url, id);
tracing::info!(method = "DELETE", %url, "Sending Calendar API request");
let res = self
.client
.delete(&url)
.bearer_auth(token)
.send()
.await
.map_err(AppError::Network)?;
let status = res.status();
tracing::info!(%status, %url, "Received Calendar API response");
if !status.is_success() {
let error_body = res.text().await.unwrap_or_default();
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
return Err(AppError::Internal(format!(
"Failed to delete event: {} - {}",
status, error_body
)));
}
Ok(())
}
}

5
src/domain/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod agent;
pub mod auth;
pub mod calendar;
pub mod notifications;
pub mod tasks;

View file

@ -0,0 +1 @@
pub mod push;

View file

@ -0,0 +1,113 @@
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use web_push::*;
pub struct PushSender {
pub private_key: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PushSubscription {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
fn ensure_pem(input: &str) -> String {
let input = input.trim();
if input.contains("-----BEGIN") {
return input.to_string();
}
if input.starts_with("MHc") {
format!(
"-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----",
input
)
} else {
format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----",
input
)
}
}
impl PushSender {
pub fn new(private_key_pem: &str) -> AppResult<Self> {
let pem = ensure_pem(private_key_pem);
// Validate key immediately to catch config errors early
let _ =
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&pem)).map_err(|e| {
crate::error::AppError::Internal(format!("Invalid VAPID private key: {}", e))
})?;
Ok(Self { private_key: pem })
}
pub fn get_public_key(&self) -> AppResult<Vec<u8>> {
let builder =
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key))
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
Ok(builder.get_public_key())
}
pub async fn send_notification(
&self,
subscription: &PushSubscription,
title: &str,
body: &str,
task_id: Option<Uuid>,
run_id: Option<Uuid>,
) -> AppResult<()> {
let subscription_info = SubscriptionInfo::new(
subscription.endpoint.clone(),
subscription.p256dh.clone(),
subscription.auth.clone(),
);
let builder =
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key))
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let mut builder = builder.add_sub_info(&subscription_info);
builder.add_claim("sub", "mailto:pavel@flegr.me");
let vapid_signature = builder
.build()
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(vapid_signature);
let payload = serde_json::to_vec(&serde_json::json!({
"title": title,
"body": body,
"task_id": task_id,
"run_id": run_id,
}))
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
builder.set_payload(ContentEncoding::Aes128Gcm, &payload);
let message = builder
.build()
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let client = IsahcWebPushClient::new()
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
client.send(message).await.map_err(|e| {
tracing::error!("Failed to send push notification: {}", e);
crate::error::AppError::Internal(e.to_string())
})?;
tracing::info!(
"Push notification sent successfully to {}",
subscription.endpoint
);
Ok(())
}
}

214
src/domain/tasks.rs Normal file
View file

@ -0,0 +1,214 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::config::Config;
use crate::domain::agent::Agent;
use crate::entities::task::Entity as Task;
use crate::entities::task_run::{self, Entity as TaskRun};
use crate::entities::{push_subscription, task_subscription};
use crate::scheduler::Scheduler;
#[derive(Deserialize)]
pub struct CreateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
pub struct TaskResponse {
pub id: Uuid,
pub goal: String,
pub cron: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
pub runs: Vec<TaskRunResponse>,
}
#[derive(Serialize, Clone, Debug)]
pub struct TaskRunResponse {
pub id: Uuid,
pub status: String,
pub logs: String,
pub answer: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
#[derive(Serialize, Clone, Debug)]
pub struct RecentRunResponse {
pub id: Uuid,
pub task_id: Uuid,
pub goal: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
use crate::error::AppResult;
pub async fn execute_agent_run(
db: &DatabaseConnection,
_scheduler: &Arc<Scheduler>,
config: &Arc<Config>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
task_id: Uuid,
goal: String,
) -> AppResult<TaskResponse> {
let run_id = Uuid::new_v4();
tracing::info!(%task_id, %run_id, "Starting background agent execution run");
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(db)
.await
.map_err(crate::error::AppError::Database)?;
let _ = _scheduler
.tx
.send(crate::server::notifications::WsEvent::RunStarted(
RecentRunResponse {
id: run_id,
task_id,
goal: goal.clone(),
status: "running".to_string(),
created_at: Utc::now().into(),
},
));
let mut agent = Agent::new(
db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
calendar_client.clone(),
None,
goal.clone(),
)?;
let db_bg = db.clone();
let config_bg = config.clone();
let scheduler_bg = _scheduler.clone();
let task_id_bg = task_id;
tokio::spawn(async move {
let (logs, answer, status) = match agent.run(&config_bg).await {
Ok((logs, answer)) => {
tracing::info!(task_id = %task_id_bg, run_id = %run_id, "Agent execution completed successfully");
(logs, answer, "completed".to_string())
}
Err(e) => {
tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Agent execution failed");
(
format!("Execution failed: {}", e),
None,
"failed".to_string(),
)
}
};
let run_update = task_run::ActiveModel {
id: Set(run_id),
logs: Set(logs),
answer: Set(answer),
status: Set(status.clone()),
..Default::default()
};
if let Err(e) = run_update.update(&db_bg).await {
tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Failed to update run record");
}
if let Ok(task_response) = get_task_inner(task_id_bg, &db_bg).await {
let _ = scheduler_bg
.tx
.send(crate::server::notifications::WsEvent::RunFinished(
task_response.clone(),
));
// Send Push Notifications to subscribers
if let Ok(subscriptions) = task_subscription::Entity::find()
.filter(task_subscription::Column::TaskId.eq(task_id_bg))
.all(&db_bg)
.await
{
for sub in subscriptions {
if let Ok(push_subs) = push_subscription::Entity::find()
.filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone()))
.all(&db_bg)
.await
{
for push_sub in push_subs {
let sender = scheduler_bg.push_sender.clone();
let goal = task_response.goal.clone();
let status_bg = status.clone();
let sub_data = crate::domain::notifications::push::PushSubscription {
endpoint: push_sub.endpoint,
p256dh: push_sub.p256dh,
auth: push_sub.auth,
};
tokio::spawn(async move {
let _ = sender
.send_notification(
&sub_data,
&format!("Task Completed: {}", status_bg),
&goal,
Some(task_id_bg),
Some(run_id),
)
.await;
});
}
}
}
}
}
});
get_task_inner(task_id, db).await
}
pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult<TaskResponse> {
let results = Task::find_by_id(id)
.find_with_related(TaskRun)
.all(db)
.await
.map_err(crate::error::AppError::Database)?;
let (t, mut runs) = results
.into_iter()
.next()
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".into()))?;
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(TaskResponse {
id: t.id,
goal: t.goal,
cron: t.cron,
created_at: t.created_at,
runs: runs
.into_iter()
.map(|r| TaskRunResponse {
id: r.id,
status: r.status,
logs: r.logs,
answer: r.answer,
created_at: r.created_at,
})
.collect(),
})
}

View file

@ -1,2 +1,4 @@
pub mod push_subscription;
pub mod task; pub mod task;
pub mod task_run; pub mod task_run;
pub mod task_subscription;

View file

@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "push_subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_sub: String,
pub endpoint: String,
pub p256dh: String,
pub auth: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "task_subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_sub: String,
pub task_id: Uuid,
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 {}

57
src/error.rs Normal file
View file

@ -0,0 +1,57 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Configuration error: {0}")]
Config(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Internal server error: {0}")]
Internal(String),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Invalid request: {0}")]
InvalidRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match &self {
AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err.clone()),
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err.clone()),
AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()),
AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err.clone()),
};
if status.is_server_error() || status.is_client_error() {
tracing::error!(%status, error = %self, "AppError converted to response");
}
let body = Json(json!({
"error": error_message,
}));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;

View file

@ -1,16 +1,28 @@
mod agent; mod config;
mod api; mod domain;
mod auth;
mod entities; mod entities;
mod error;
mod scheduler; mod scheduler;
mod server; mod server;
mod tools; #[cfg(test)]
mod tests;
use tracing::info;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "bot=info,axum=info".into()),
)
.init();
server::start(&db_url).await?; info!("Starting Antigravity Agent...");
let config = config::Config::from_env()?;
server::start(config).await?;
Ok(()) Ok(())
} }

View file

@ -1,8 +1,10 @@
use crate::agent::Agent; use crate::domain::agent::Agent;
use crate::entities::task::Entity as Task; use crate::entities::task::Entity as Task;
use crate::entities::task_run; use crate::entities::task_run;
use crate::error::{AppError, AppResult};
use dashmap::DashMap; use dashmap::DashMap;
use sea_orm::{DatabaseConnection, EntityTrait, Set}; use sea_orm::{DatabaseConnection, EntityTrait, Set};
use std::sync::Arc;
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio_cron_scheduler::{Job, JobScheduler};
use uuid::Uuid; use uuid::Uuid;
@ -10,78 +12,107 @@ pub struct Scheduler {
scheduler: JobScheduler, scheduler: JobScheduler,
db: DatabaseConnection, db: DatabaseConnection,
tasks_to_jobs: DashMap<Uuid, Uuid>, tasks_to_jobs: DashMap<Uuid, Uuid>,
zen_api_key: Option<String>, config: Arc<crate::config::Config>,
tavily_api_key: Option<String>, pub calendar_client: Arc<crate::domain::calendar::CalendarClient>,
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
pub push_sender: Arc<crate::domain::notifications::push::PushSender>,
} }
impl Scheduler { impl Scheduler {
pub async fn new( pub async fn new(
db: DatabaseConnection, db: DatabaseConnection,
zen_api_key: Option<String>, config: Arc<crate::config::Config>,
tavily_api_key: Option<String>, calendar_client: Arc<crate::domain::calendar::CalendarClient>,
) -> Result<Self, Box<dyn std::error::Error>> { tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
let scheduler = JobScheduler::new().await?; push_sender: Arc<crate::domain::notifications::push::PushSender>,
scheduler.start().await?; ) -> AppResult<Self> {
let scheduler = JobScheduler::new()
.await
.map_err(|e| AppError::Internal(format!("Failed to create scheduler: {}", e)))?;
scheduler
.start()
.await
.map_err(|e| AppError::Internal(format!("Failed to start scheduler: {}", e)))?;
Ok(Self { Ok(Self {
scheduler, scheduler,
db, db,
tasks_to_jobs: DashMap::new(), tasks_to_jobs: DashMap::new(),
zen_api_key, config,
tavily_api_key, calendar_client,
tx,
push_sender,
}) })
} }
pub async fn add_task_job( pub async fn add_task_job(&self, task_id: Uuid, cron_expr: &str) -> AppResult<()> {
&self,
task_id: Uuid,
cron_expr: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Remove existing job if any // Remove existing job if any
if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) { if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) {
let _ = self.scheduler.remove(&old_job_id).await; let _ = self.scheduler.remove(&old_job_id).await;
} }
let db = self.db.clone(); let db = self.db.clone();
let zen_key = self.zen_api_key.clone(); let config = self.config.clone();
let tavily_key = self.tavily_api_key.clone(); let tx = self.tx.clone();
let push_sender = self.push_sender.clone();
let calendar_client = self.calendar_client.clone();
let job = Job::new_async(cron_expr, move |_uuid, _l| { let job = Job::new_async(cron_expr, move |_uuid, _l| {
let db = db.clone(); let db = db.clone();
let zen_key = zen_key.clone(); let config = config.clone();
let tavily_key = tavily_key.clone(); let tx = tx.clone();
let push_sender = push_sender.clone();
let calendar_client = calendar_client.clone();
Box::pin(async move { Box::pin(async move {
if let Err(e) = Self::run_task(db, zen_key, tavily_key, task_id).await { if let Err(e) =
eprintln!("Error in scheduled task {}: {}", task_id, e); Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await
{
tracing::error!("Error in scheduled task {}: {}", task_id, e);
} }
}) })
})?; })
.map_err(|e| AppError::Internal(format!("Failed to create job: {}", e)))?;
let job_id = self.scheduler.add(job).await?; let job_id = self
.scheduler
.add(job)
.await
.map_err(|e| AppError::Internal(format!("Failed to add job: {}", e)))?;
self.tasks_to_jobs.insert(task_id, job_id); self.tasks_to_jobs.insert(task_id, job_id);
tracing::info!(%task_id, %cron_expr, "Added task to scheduler");
Ok(()) Ok(())
} }
pub async fn remove_task_job(&self, task_id: Uuid) -> Result<(), Box<dyn std::error::Error>> { pub async fn remove_task_job(&self, task_id: Uuid) -> AppResult<()> {
if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) { if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) {
self.scheduler.remove(&job_id).await?; self.scheduler
.remove(&job_id)
.await
.map_err(|e| AppError::Internal(format!("Failed to remove job: {}", e)))?;
tracing::info!(%task_id, "Removed task from scheduler");
} }
Ok(()) Ok(())
} }
async fn run_task( async fn run_task(
db: DatabaseConnection, db: DatabaseConnection,
zen_key: Option<String>, config: Arc<crate::config::Config>,
tavily_key: Option<String>, calendar_client: Arc<crate::domain::calendar::CalendarClient>,
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
push_sender: Arc<crate::domain::notifications::push::PushSender>,
task_id: Uuid, task_id: Uuid,
) -> Result<(), Box<dyn std::error::Error>> { ) -> AppResult<()> {
let task = Task::find_by_id(task_id) let task = Task::find_by_id(task_id)
.one(&db) .one(&db)
.await? .await
.ok_or("Task not found")?; .map_err(AppError::Database)?
.ok_or_else(|| AppError::NotFound("Task not found".into()))?;
// Create a new run entry // Create a new run entry
let run_id = Uuid::new_v4(); let run_id = Uuid::new_v4();
tracing::info!(task_id = %task_id, run_id = %run_id, "Starting scheduled task execution");
let run = task_run::ActiveModel { let run = task_run::ActiveModel {
id: Set(run_id), id: Set(run_id),
task_id: Set(task_id), task_id: Set(task_id),
@ -92,25 +123,104 @@ impl Scheduler {
}; };
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
run.insert(&db).await?; run.insert(&db).await.map_err(AppError::Database)?;
let _ = tx.send(crate::server::notifications::WsEvent::RunStarted(
crate::domain::tasks::RecentRunResponse {
id: run_id,
task_id,
goal: task.goal.clone(),
status: "running".to_string(),
created_at: chrono::Utc::now().into(),
},
));
// Start agent in background // Start agent in background
let mut agent = Agent::new(zen_key, tavily_key, task.goal.clone())?; let mut agent = Agent::new(
db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
calendar_client.clone(),
None,
task.goal.clone(),
)?;
tokio::spawn(async move { tokio::spawn(async move {
let (logs, answer) = match agent.run().await { let (logs, answer, status) = match agent.run(&config).await {
Ok(res) => res, Ok((logs, answer)) => {
Err(e) => (format!("Scheduled run failed: {}", e), None), tracing::info!(task_id = %task_id, run_id = %run_id, "Scheduled task execution completed successfully");
(logs, answer, "completed".to_string())
}
Err(e) => {
tracing::error!(task_id = %task_id, run_id = %run_id, error = %e, "Scheduled task execution failed");
(
format!("Scheduled run failed: {}", e),
None,
"failed".to_string(),
)
}
}; };
let run_complete = task_run::ActiveModel { let run_complete = task_run::ActiveModel {
id: Set(run_id), id: Set(run_id),
status: Set("completed".to_string()), status: Set(status.clone()),
logs: Set(logs), logs: Set(logs),
answer: Set(answer), answer: Set(answer),
..Default::default() ..Default::default()
}; };
let _ = run_complete.update(&db).await; if let Err(e) = run_complete.update(&db).await {
tracing::error!(
"Failed to update scheduled run status for task {}: {}",
task_id,
e
);
}
if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await {
let _ = tx.send(crate::server::notifications::WsEvent::RunFinished(
task_response.clone(),
));
// Push notifications for scheduled runs
use crate::entities::{push_subscription, task_subscription};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
if let Ok(subscriptions) = task_subscription::Entity::find()
.filter(task_subscription::Column::TaskId.eq(task_id))
.all(&db)
.await
{
for sub in subscriptions {
if let Ok(push_subs) = push_subscription::Entity::find()
.filter(push_subscription::Column::UserSub.eq(sub.user_sub))
.all(&db)
.await
{
for push_sub in push_subs {
let sender = push_sender.clone();
let sub_data =
crate::domain::notifications::push::PushSubscription {
endpoint: push_sub.endpoint,
p256dh: push_sub.p256dh,
auth: push_sub.auth,
};
let goal = task_response.goal.clone();
let status = status.clone();
tokio::spawn(async move {
let _ = sender
.send_notification(
&sub_data,
&format!("Scheduled Task Completed: {}", status),
&goal,
Some(task_id),
Some(run_id),
)
.await;
});
}
}
}
}
}
}); });
Ok(()) Ok(())

View file

@ -1,453 +0,0 @@
use axum::{
Json, RequestPartsExt, Router,
extract::{FromRef, FromRequestParts, Path, Query, State},
http::{StatusCode, request::Parts},
routing::{get, post},
};
use axum_extra::{
TypedHeader,
headers::{Authorization, authorization::Bearer},
};
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use uuid::Uuid;
use crate::agent::Agent;
use crate::entities::task::{self, Entity as Task};
use crate::entities::task_run::{self, Entity as TaskRun};
use crate::scheduler::Scheduler;
use migration::{Migrator, MigratorTrait};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub scheduler: Arc<Scheduler>,
pub zen_api_key: Option<String>,
pub tavily_api_key: Option<String>,
pub verifier: Arc<crate::auth::JwksVerifier>,
pub authenticator: Arc<crate::auth::Authenticator>,
}
#[derive(Deserialize)]
pub struct CreateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Serialize)]
pub struct TaskResponse {
pub id: Uuid,
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 logs: String,
pub answer: Option<String>,
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>> {
let db = Database::connect(db_url).await?;
Migrator::up(&db, None).await?;
let zen_api_key = std::env::var("ZEN_API_KEY").ok();
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
let scheduler =
Arc::new(Scheduler::new(db.clone(), zen_api_key.clone(), tavily_api_key.clone()).await?);
// Load existing scheduled tasks
let existing_tasks = Task::find().all(&db).await?;
for task in existing_tasks {
if let Some(cron) = task.cron {
let _ = scheduler.add_task_job(task.id, &cron).await;
}
}
let authentik_issuer =
std::env::var("AUTHENTIK_ISSUER").map_err(|_| "AUTHENTIK_ISSUER not set")?;
let authentik_client_id =
std::env::var("AUTHENTIK_CLIENT_ID").map_err(|_| "AUTHENTIK_CLIENT_ID not set")?;
let authentik_client_secret =
std::env::var("AUTHENTIK_CLIENT_SECRET").map_err(|_| "AUTHENTIK_CLIENT_SECRET not set")?;
let verifier = Arc::new(crate::auth::JwksVerifier::new(authentik_issuer.clone()).await?);
let authenticator = Arc::new(
crate::auth::Authenticator::new(
authentik_issuer,
authentik_client_id,
authentik_client_secret,
)
.await?,
);
let state = Arc::new(AppState {
db,
scheduler,
zen_api_key,
tavily_api_key,
verifier,
authenticator,
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/api/tasks", post(create_task).get(list_tasks))
.route("/api/tasks/:id", get(get_task).put(update_task))
.route("/api/tasks/:id/runs", post(rerun_task))
.route("/api/runs/recent", get(get_recent_runs))
.route("/api/auth/callback", get(auth_callback))
.route("/api/auth/refresh", post(auth_refresh))
.layer(cors)
.with_state(state);
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("Server running on http://localhost:{}", port);
axum::serve(listener, app).await?;
Ok(())
}
async fn list_tasks(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> {
let tasks = Task::find()
.find_with_related(TaskRun)
.order_by_desc(task::Column::CreatedAt)
.all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let response = tasks
.into_iter()
.map(|(t, mut runs)| {
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
TaskResponse {
id: t.id,
goal: t.goal,
cron: t.cron,
created_at: t.created_at,
runs: runs
.into_iter()
.map(|r| TaskRunResponse {
id: r.id,
status: r.status,
logs: r.logs,
answer: r.answer,
created_at: r.created_at,
})
.collect(),
}
})
.collect();
Ok(Json(response))
}
async fn create_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTaskRequest>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
let task_id = Uuid::new_v4();
// Initial task save
let new_task = task::ActiveModel {
id: Set(task_id),
goal: Set(payload.goal.clone()),
cron: Set(payload.cron.clone()),
created_at: Set(Utc::now().into()),
};
new_task
.insert(&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(task_id, cron).await;
} else {
let _ = state.scheduler.remove_task_job(task_id).await;
}
get_task_inner(task_id, &state).await.map(Json)
}
async fn rerun_task(
_user: AuthenticatedUser,
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(
state.zen_api_key.clone(),
state.tavily_api_key.clone(),
goal.clone(),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (logs, answer) = match agent.run().await {
Ok((logs, answer)) => (logs, answer),
Err(e) => (format!("Execution failed: {}", e), None),
};
// Update with final logs and status
let mut run: task_run::ActiveModel = TaskRun::find_by_id(run_id)
.one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::NOT_FOUND,
"Run not found after insert".to_string(),
))?
.into();
run.logs = Set(logs.clone());
run.answer = Set(answer.clone());
run.status = Set("completed".to_string());
run.update(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
get_task_inner(task_id, &state).await.map(Json)
}
async fn update_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
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_inner(id, &state).await.map(Json)
}
#[allow(dead_code)]
pub struct AuthenticatedUser(pub crate::auth::Claims);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
Arc<AppState>: axum::extract::FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = Arc::<AppState>::from_ref(state);
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"Missing or invalid Authorization header".to_string(),
)
})?;
let claims = app_state
.verifier
.verify(bearer.token())
.await
.map_err(|e| {
(
StatusCode::UNAUTHORIZED,
format!("Token verification failed: {}", e),
)
})?;
Ok(AuthenticatedUser(claims))
}
}
#[derive(Deserialize)]
pub struct AuthCallbackQuery {
pub code: String,
pub redirect_uri: String,
}
#[derive(Deserialize)]
struct RefreshRequest {
refresh_token: String,
}
async fn auth_refresh(
State(state): State<Arc<AppState>>,
Json(payload): Json<RefreshRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state
.authenticator
.refresh_token(payload.refresh_token)
.await
.map(Json)
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))
}
async fn auth_callback(
State(state): State<Arc<AppState>>,
Query(query): Query<AuthCallbackQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state
.authenticator
.exchange_code(query.code, query.redirect_uri)
.await
.map(Json)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Token exchange failed: {}", e),
)
})
}
async fn get_task(
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
get_task_inner(id, &state).await.map(Json)
}
async fn get_task_inner(id: Uuid, state: &AppState) -> Result<TaskResponse, (StatusCode, String)> {
let results = Task::find_by_id(id)
.find_with_related(TaskRun)
.all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (t, mut runs) = results
.into_iter()
.next()
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(TaskResponse {
id: t.id,
goal: t.goal,
cron: t.cron,
created_at: t.created_at,
runs: runs
.into_iter()
.map(|r| TaskRunResponse {
id: r.id,
status: r.status,
logs: r.logs,
answer: r.answer,
created_at: r.created_at,
})
.collect(),
})
}
async fn get_recent_runs(
_user: AuthenticatedUser,
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))
}

169
src/server/auth.rs Normal file
View file

@ -0,0 +1,169 @@
use axum::{
Json, RequestPartsExt,
extract::{FromRef, FromRequestParts, Query, State},
http::request::Parts,
response::IntoResponse,
};
use axum_extra::{
TypedHeader,
extract::cookie::{Cookie, CookieJar, SameSite},
headers::{Authorization, authorization::Bearer},
};
use std::sync::Arc;
use super::AppState;
use crate::error::{AppError, AppResult};
pub struct AuthenticatedUser(pub crate::domain::auth::Claims);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
Arc<AppState>: axum::extract::FromRef<S>,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = Arc::<AppState>::from_ref(state);
let token = if let Ok(TypedHeader(Authorization(bearer))) =
parts.extract::<TypedHeader<Authorization<Bearer>>>().await
{
Some(bearer.token().to_string())
} else {
let jar = parts
.extract::<CookieJar>()
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
jar.get("access_token")
.map(|cookie| cookie.value().to_string())
};
let token = token
.ok_or_else(|| AppError::Unauthorized("Missing or invalid access token".into()))?;
let claims = app_state
.verifier
.verify(&token)
.await
.map_err(|e| AppError::Unauthorized(format!("Token verification failed: {}", e)))?;
Ok(AuthenticatedUser(claims))
}
}
#[derive(serde::Deserialize)]
pub struct AuthCallbackQuery {
pub code: String,
pub redirect_uri: String,
}
#[derive(serde::Deserialize)]
pub struct RefreshRequest {
pub refresh_token: Option<String>,
}
pub async fn auth_refresh(
State(state): State<Arc<AppState>>,
jar: CookieJar,
Json(payload): Json<RefreshRequest>,
) -> AppResult<impl IntoResponse> {
let refresh_token = payload
.refresh_token
.filter(|token| !token.is_empty())
.or_else(|| {
jar.get("refresh_token")
.map(|cookie| cookie.value().to_string())
})
.ok_or_else(|| AppError::Unauthorized("Missing refresh token".into()))?;
let data = state
.authenticator
.refresh_token(refresh_token)
.await
.map_err(|e| AppError::Unauthorized(e.to_string()))?;
let jar = update_auth_cookies(jar, &data, &state.config);
Ok((jar, Json(data)))
}
pub async fn auth_callback(
State(state): State<Arc<AppState>>,
jar: CookieJar,
Query(query): Query<AuthCallbackQuery>,
) -> AppResult<impl IntoResponse> {
let data = state
.authenticator
.exchange_code(query.code, query.redirect_uri)
.await
.map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?;
let jar = update_auth_cookies(jar, &data, &state.config);
Ok((jar, Json(data)))
}
pub async fn auth_logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> impl IntoResponse {
let jar = clear_auth_cookies(jar, &state.config);
(jar, axum::http::StatusCode::NO_CONTENT)
}
pub async fn auth_session(user: AuthenticatedUser) -> Json<serde_json::Value> {
Json(serde_json::json!({
"authenticated": true,
"user": user.0
}))
}
fn secure(config: &crate::config::Config) -> bool {
config.cookie_secure
}
pub fn update_auth_cookies(
jar: CookieJar,
data: &serde_json::Value,
config: &crate::config::Config,
) -> CookieJar {
let access_token = data.get("access_token");
let refresh_token = data.get("refresh_token");
let mut jar = jar;
if let Some(token) = access_token.and_then(|t| t.as_str()) {
let cookie = Cookie::build(("access_token", token.to_owned()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure(config))
.build();
jar = jar.add(cookie);
}
if let Some(token) = refresh_token.and_then(|t| t.as_str()) {
let cookie = Cookie::build(("refresh_token", token.to_owned()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure(config))
.build();
jar = jar.add(cookie);
}
jar
}
pub fn clear_auth_cookies(jar: CookieJar, config: &crate::config::Config) -> CookieJar {
let mut jar = jar;
for name in ["access_token", "refresh_token"] {
let cookie = Cookie::build((name, ""))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure(config))
.max_age(cookie::time::Duration::seconds(0))
.build();
jar = jar.add(cookie);
}
jar
}

68
src/server/chat.rs Normal file
View file

@ -0,0 +1,68 @@
use crate::domain::agent::api::Message;
use crate::error::AppError;
use crate::server::AppState;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct ChatPayload {
pub messages: Vec<Message>,
}
#[derive(Debug, Serialize)]
pub struct ChatResult {
pub message: Message,
}
pub async fn chat_handler(
State(state): State<Arc<AppState>>,
user: crate::server::auth::AuthenticatedUser,
Json(payload): Json<ChatPayload>,
) -> Result<Json<ChatResult>, AppError> {
let msg_count = payload.messages.len();
tracing::info!("Received chat request with {} messages", msg_count);
let mut messages = payload.messages;
// Inject current date awareness if not already present or as a fresh system message
let now = chrono::Local::now();
let date_str = now.format("%A, %B %e, %Y at %l:%M %P").to_string();
messages.insert(
0,
Message {
role: "system".to_string(),
content: Some(format!(
"The current date and time is {}. Today is {}. You are in interactive chat mode.",
date_str,
now.format("%Y-%m-%d")
)),
tool_calls: None,
tool_call_id: None,
},
);
let mut agent = crate::domain::agent::Agent::with_messages(
state.db.clone(),
state.config.zen_api_key.clone(),
state.config.tavily_api_key.clone(),
state.calendar_client.clone(),
Some(user.0.sub),
messages,
)?;
tracing::info!("Starting interactive agent turn");
let assistant_message =
match tokio::time::timeout(std::time::Duration::from_secs(180), agent.execute_turn()).await
{
Ok(res) => res?,
Err(_) => {
tracing::error!("Interactive chat agent turn timed out after 180s");
return Err(AppError::Internal("Agent turn timed out".into()));
}
};
Ok(Json(ChatResult {
message: assistant_message,
}))
}

219
src/server/mod.rs Normal file
View file

@ -0,0 +1,219 @@
pub mod auth;
pub mod chat;
pub mod notifications;
pub mod tasks;
use axum::{
Router,
http::HeaderValue,
routing::{get, post},
};
use migration::{Migrator, MigratorTrait};
use sea_orm::{Database, DatabaseConnection, EntityTrait};
use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::scheduler::Scheduler;
use crate::error::AppResult;
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub scheduler: Arc<Scheduler>,
pub config: Arc<crate::config::Config>,
pub verifier: Arc<crate::domain::auth::JwksVerifier>,
pub authenticator: Arc<crate::domain::auth::Authenticator>,
pub calendar_client: Arc<crate::domain::calendar::CalendarClient>,
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
}
pub async fn start(config: crate::config::Config) -> AppResult<()> {
let db = setup_database(&config.database_url).await?;
let config = Arc::new(config);
let (tx, _) = tokio::sync::broadcast::channel(100);
let push_sender = Arc::new(crate::domain::notifications::push::PushSender::new(
&config.vapid_private_key.clone(),
)?);
let (verifier, authenticator) = setup_auth(&config).await?;
let calendar_client = Arc::new(crate::domain::calendar::CalendarClient::new(
config.calendar_api_url.clone(),
authenticator.clone(),
));
let scheduler = Arc::new(
Scheduler::new(
db.clone(),
config.clone(),
calendar_client.clone(),
tx.clone(),
push_sender.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
);
// Load existing scheduled tasks
use crate::entities::task::Entity as Task;
let existing_tasks = Task::find()
.all(&db)
.await
.map_err(crate::error::AppError::Database)?;
for task in existing_tasks {
if let Some(cron) = task.cron {
let _ = scheduler.add_task_job(task.id, &cron).await;
}
}
let state = Arc::new(AppState {
db,
scheduler,
config: config.clone(),
verifier,
authenticator,
calendar_client,
tx,
});
let app = build_app(state, &config);
let addr = format!("0.0.0.0:{}", config.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
tracing::info!("Server running on http://localhost:{}", config.port);
axum::serve(listener, app)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
Ok(())
}
async fn setup_database(database_url: &str) -> AppResult<DatabaseConnection> {
let db = Database::connect(database_url)
.await
.map_err(crate::error::AppError::Database)?;
Migrator::up(&db, None)
.await
.map_err(crate::error::AppError::Database)?;
Ok(db)
}
async fn setup_auth(
config: &crate::config::Config,
) -> AppResult<(
Arc<crate::domain::auth::JwksVerifier>,
Arc<crate::domain::auth::Authenticator>,
)> {
let verifier = Arc::new(
crate::domain::auth::JwksVerifier::new(
config.authentik_issuer.clone(),
config.authentik_client_id.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
);
let authenticator = Arc::new(
crate::domain::auth::Authenticator::new(
config.authentik_issuer.clone(),
config.authentik_client_id.clone(),
config.authentik_client_secret.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
);
Ok((verifier, authenticator))
}
fn build_app(state: Arc<AppState>, config: &crate::config::Config) -> Router {
let cors = build_cors_layer(config);
Router::new()
.route("/api/tasks", post(tasks::create_task).get(tasks::list_tasks))
.route("/api/tasks/:id", get(tasks::get_task).put(tasks::update_task))
.route("/api/tasks/:id/runs", post(tasks::rerun_task))
.route("/api/runs/recent", get(tasks::get_recent_runs))
.route("/api/auth/session", get(auth::auth_session))
.route("/api/auth/callback", get(auth::auth_callback))
.route("/api/auth/refresh", post(auth::auth_refresh))
.route("/api/auth/logout", post(auth::auth_logout))
.route("/api/chat", post(chat::chat_handler))
.route("/api/ws", get(notifications::ws_handler))
.route("/api/notifications/register", post(notifications::push_handlers::register_push))
.route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key))
.route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status))
.route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task))
.layer(axum::middleware::from_fn(log_error_responses))
.layer(cors)
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"),
))
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
))
.layer(tower_http::limit::RequestBodyLimitLayer::new(1024 * 1024)) // 1MB limit
.with_state(state)
}
async fn log_error_responses(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let method = req.method().clone();
let uri = req.uri().clone();
let res = next.run(req).await;
let status = res.status();
if status.is_client_error() || status.is_server_error() {
tracing::error!(%method, %uri, %status, "Response error");
}
res
}
fn build_cors_layer(config: &crate::config::Config) -> CorsLayer {
let allow_origin = if let Some(origins) = &config.cors_allowed_origins {
let values: Vec<HeaderValue> = origins
.split(',')
.map(|origin| origin.trim())
.filter(|origin| !origin.is_empty())
.filter_map(|origin| HeaderValue::from_str(origin).ok())
.collect();
if values.is_empty() {
AllowOrigin::mirror_request()
} else {
AllowOrigin::list(values)
}
} else {
AllowOrigin::mirror_request()
};
CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::header::ACCEPT,
])
.allow_credentials(true)
}

View file

@ -0,0 +1,48 @@
pub mod push_handlers;
use crate::domain::tasks::{RecentRunResponse, TaskResponse};
use crate::server::AppState;
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use serde::Serialize;
use std::sync::Arc;
#[derive(Serialize, Clone, Debug)]
#[serde(tag = "type", content = "data")]
pub enum WsEvent {
TaskCreated(TaskResponse),
TaskUpdated(TaskResponse),
RunStarted(RecentRunResponse),
RunFinished(TaskResponse),
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.tx.subscribe();
while let Ok(event) = rx.recv().await {
let msg = match serde_json::to_string(&event) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to serialize WsEvent: {}", e);
continue;
}
};
if socket.send(Message::Text(msg)).await.is_err() {
// Client disconnected
break;
}
}
}

View file

@ -0,0 +1,133 @@
use axum::{
Json,
extract::{Path, State},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use std::sync::Arc;
use uuid::Uuid;
use crate::entities::{push_subscription, task_subscription};
use crate::error::{AppError, AppResult};
use crate::server::AppState;
use crate::server::auth::AuthenticatedUser;
use base64::Engine;
#[derive(serde::Deserialize)]
pub struct RegisterPushRequest {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
pub async fn register_push(
State(state): State<Arc<AppState>>,
user: AuthenticatedUser,
Json(payload): Json<RegisterPushRequest>,
) -> AppResult<Json<serde_json::Value>> {
let user_sub = user.0.sub;
tracing::info!(
"Registering push subscription for user: {} with endpoint: {}",
user_sub,
payload.endpoint
);
// Check if subscription exists
let existing = push_subscription::Entity::find()
.filter(push_subscription::Column::UserSub.eq(user_sub.clone()))
.filter(push_subscription::Column::Endpoint.eq(payload.endpoint.clone()))
.one(&state.db)
.await
.map_err(AppError::Database)?;
if existing.is_none() {
let new_sub = push_subscription::ActiveModel {
id: Set(Uuid::new_v4()),
user_sub: Set(user_sub),
endpoint: Set(payload.endpoint),
p256dh: Set(payload.p256dh),
auth: Set(payload.auth),
created_at: Set(Utc::now().into()),
};
new_sub
.insert(&state.db)
.await
.map_err(AppError::Database)?;
}
Ok(Json(serde_json::json!({ "status": "registered" })))
}
pub async fn subscribe_task(
State(state): State<Arc<AppState>>,
user: AuthenticatedUser,
Path(task_id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
let user_sub = user.0.sub;
let existing = task_subscription::Entity::find()
.filter(task_subscription::Column::UserSub.eq(user_sub.clone()))
.filter(task_subscription::Column::TaskId.eq(task_id))
.one(&state.db)
.await
.map_err(AppError::Database)?;
if existing.is_none() {
let new_sub = task_subscription::ActiveModel {
id: Set(Uuid::new_v4()),
user_sub: Set(user_sub),
task_id: Set(task_id),
created_at: Set(Utc::now().into()),
};
new_sub
.insert(&state.db)
.await
.map_err(AppError::Database)?;
}
Ok(Json(serde_json::json!({ "status": "subscribed" })))
}
pub async fn unsubscribe_task(
State(state): State<Arc<AppState>>,
user: AuthenticatedUser,
Path(task_id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
let user_sub = user.0.sub;
task_subscription::Entity::delete_many()
.filter(task_subscription::Column::UserSub.eq(user_sub))
.filter(task_subscription::Column::TaskId.eq(task_id))
.exec(&state.db)
.await
.map_err(AppError::Database)?;
Ok(Json(serde_json::json!({ "status": "unsubscribed" })))
}
pub async fn get_vapid_key(
State(state): State<Arc<AppState>>,
) -> AppResult<Json<serde_json::Value>> {
let public_key = state.scheduler.push_sender.get_public_key()?;
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key);
Ok(Json(serde_json::json!({ "publicKey": encoded })))
}
pub async fn get_subscription_status(
State(state): State<Arc<AppState>>,
user: AuthenticatedUser,
Path(task_id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
let user_sub = user.0.sub;
let existing = task_subscription::Entity::find()
.filter(task_subscription::Column::UserSub.eq(user_sub))
.filter(task_subscription::Column::TaskId.eq(task_id))
.one(&state.db)
.await
.map_err(AppError::Database)?;
Ok(Json(
serde_json::json!({ "isSubscribed": existing.is_some() }),
))
}

205
src/server/tasks.rs Normal file
View file

@ -0,0 +1,205 @@
use axum::{
Json,
extract::{Path, State},
};
use sea_orm::{EntityTrait, QueryOrder, QuerySelect};
use std::sync::Arc;
use uuid::Uuid;
use super::AppState;
use super::auth::AuthenticatedUser;
use crate::domain::tasks::{
self, CreateTaskRequest, RecentRunResponse, TaskResponse, UpdateTaskRequest,
};
use crate::error::AppResult;
pub async fn list_tasks(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
) -> AppResult<Json<Vec<TaskResponse>>> {
let tasks = crate::entities::task::Entity::find()
.find_with_related(crate::entities::task_run::Entity)
.order_by_desc(crate::entities::task::Column::CreatedAt)
.all(&state.db)
.await?;
let response = tasks
.into_iter()
.map(|(t, mut runs)| {
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
TaskResponse {
id: t.id,
goal: t.goal,
cron: t.cron,
created_at: t.created_at,
runs: runs
.into_iter()
.map(|r| tasks::TaskRunResponse {
id: r.id,
status: r.status,
logs: r.logs,
answer: r.answer,
created_at: r.created_at,
})
.collect(),
}
})
.collect();
Ok(Json(response))
}
pub async fn create_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTaskRequest>,
) -> AppResult<Json<TaskResponse>> {
if payload.goal.trim().is_empty() {
return Err(crate::error::AppError::InvalidRequest(
"Goal cannot be empty".into(),
));
}
if payload.goal.trim().len() < 5 {
return Err(crate::error::AppError::InvalidRequest(
"Goal is too short (min 5 characters)".into(),
));
}
let task_id = Uuid::new_v4();
tracing::info!(%task_id, goal = %payload.goal, "Creating new task");
let new_task = crate::entities::task::ActiveModel {
id: sea_orm::Set(task_id),
goal: sea_orm::Set(payload.goal),
cron: sea_orm::Set(payload.cron.clone()),
created_at: sea_orm::Set(chrono::Utc::now().into()),
};
use sea_orm::ActiveModelTrait;
new_task.insert(&state.db).await?;
if let Some(cron) = &payload.cron {
let _ = state.scheduler.add_task_job(task_id, cron).await;
}
let task_response = tasks::get_task_inner(task_id, &state.db).await?;
let _ = state
.tx
.send(crate::server::notifications::WsEvent::TaskCreated(
task_response.clone(),
));
Ok(Json(task_response))
}
pub async fn rerun_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> AppResult<Json<TaskResponse>> {
let task = crate::entities::task::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))?;
tracing::info!(task_id = %task.id, "Manually triggering task rerun");
tasks::execute_agent_run(
&state.db,
&state.scheduler,
&state.config,
state.calendar_client.clone(),
task.id,
task.goal,
)
.await
.map(Json)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
}
pub async fn update_task(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateTaskRequest>,
) -> AppResult<Json<TaskResponse>> {
if payload.goal.trim().is_empty() {
return Err(crate::error::AppError::InvalidRequest(
"Goal cannot be empty".into(),
));
}
if payload.goal.trim().len() < 5 {
return Err(crate::error::AppError::InvalidRequest(
"Goal is too short (min 5 characters)".into(),
));
}
let task: crate::entities::task::ActiveModel = crate::entities::task::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))?
.into();
let mut task = task;
tracing::info!(task_id = %id, goal = %payload.goal, "Updating task");
task.goal = sea_orm::Set(payload.goal);
task.cron = sea_orm::Set(payload.cron.clone());
use sea_orm::ActiveModelTrait;
task.update(&state.db).await?;
if let Some(cron) = &payload.cron {
let _ = state.scheduler.add_task_job(id, cron).await;
} else {
let _ = state.scheduler.remove_task_job(id).await;
}
let task_response = tasks::get_task_inner(id, &state.db).await?;
let _ = state
.tx
.send(crate::server::notifications::WsEvent::TaskUpdated(
task_response.clone(),
));
Ok(Json(task_response))
}
pub async fn get_task(
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> AppResult<Json<TaskResponse>> {
tasks::get_task_inner(id, &state.db)
.await
.map(Json)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
}
pub async fn get_recent_runs(
_user: AuthenticatedUser,
State(state): State<Arc<AppState>>,
) -> AppResult<Json<Vec<RecentRunResponse>>> {
let results = crate::entities::task_run::Entity::find()
.find_also_related(crate::entities::task::Entity)
.order_by_desc(crate::entities::task_run::Column::CreatedAt)
.limit(50)
.all(&state.db)
.await?;
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))
}

39
src/tests.rs Normal file
View file

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

View file

@ -1,90 +0,0 @@
use crate::api::{self, FunctionDefinition, Message, Tool, ToolCall};
use std::collections::HashMap;
pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "google_search".to_string(),
description: "Search the web for information".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "finish".to_string(),
description: "Finish the task".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The result of the task"
}
},
"required": ["result"]
}),
},
},
]
}
pub async fn handle_tool_call(
tool_call: &ToolCall,
tavily_api_key: &Option<String>,
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
let mut answer = None;
let name = &tool_call.function.name;
let (content, written) = if name == "google_search" {
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
let query = args.get("query").ok_or("Missing query argument")?;
println!(
"--- Executing tool: google_search(query: \"{}\") ---",
query
);
let search_result = if let Some(key) = tavily_api_key {
match api::perform_search(query, key).await {
Ok(results) => results,
Err(e) => format!("Search error: {}", e),
}
} else {
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
};
(search_result, false)
} else if name == "finish" {
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
let result = args.get("result").ok_or("Missing result argument")?;
println!("--- Finishing task: {}", result);
answer = Some(result.clone());
(result.clone(), true)
} else {
(format!("Error: Unknown tool {}", name), false)
};
Ok((
Message {
role: "tool".to_string(),
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call.id.clone()),
},
written,
answer,
))
}