Compare commits

...

24 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
37 changed files with 3945 additions and 197 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

3
.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.

770
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ 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", "set-header", "limit"] } 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"
@ -22,6 +22,8 @@ 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"
web-push = { version = "0.10.0", features = ["isahc-client"] }
isahc = "1.7"
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] } axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
cookie = "0.18" cookie = "0.18"
thiserror = "2.0.18" thiserror = "2.0.18"

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,22 +75,56 @@
<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 glass">
<table class="activity-table"> <div class="dashboard-content-grid">
<thead> <div id="chat-tab-panel" class="tab-panel active">
<tr> <div id="chat-container" class="chat-container glass">
<th>Directive</th> <div class="chat-header">
<th>Status</th> <h3>Neural Link</h3>
<th>Date</th> <button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear Memory</button>
</tr> </div>
</thead> <div id="chat-messages" class="chat-messages">
<tbody id="recent-runs-list"> <!-- Chat messages will be injected here -->
<!-- Recent runs will be injected here --> <div class="chat-empty-state">
</tbody> <p>Establish connection with the neural assistant.</p>
</table> </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">
<table class="activity-table">
<thead>
<tr>
<th>Directive</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody id="recent-runs-list">
<!-- Recent runs will be injected here -->
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
@ -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>

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,7 +17,10 @@ const state = {
selectedRunId: null, selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task' currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false, isEditing: false,
isAuthenticated: false isAuthenticated: false,
chatMessages: [],
activeDashboardTab: 'chat', // 'chat' or 'activity'
swRegistration: null
}; };
// DOM elements // DOM elements
const loginOverlay = document.getElementById('login-overlay'); const loginOverlay = document.getElementById('login-overlay');
@ -53,6 +57,14 @@ 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) { function updateState(newState) {
Object.assign(state, newState); Object.assign(state, newState);
@ -89,6 +101,7 @@ function renderApp() {
if (state.currentView === 'dashboard') { if (state.currentView === 'dashboard') {
fetchRecentRuns(); fetchRecentRuns();
renderChat();
} else if (state.selectedTaskId) { } else if (state.selectedTaskId) {
const task = state.tasks.find(t => t.id === state.selectedTaskId); const task = state.tasks.find(t => t.id === state.selectedTaskId);
if (task) { if (task) {
@ -146,6 +159,16 @@ async function fetchTasks() {
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 for deep link in URL
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 // Check if we should follow the latest run
let newSelectedRunId = state.selectedRunId; let newSelectedRunId = state.selectedRunId;
if (state.selectedTaskId) { if (state.selectedTaskId) {
@ -166,6 +189,12 @@ async function fetchTasks() {
tasks: newTasks, tasks: newTasks,
selectedRunId: newSelectedRunId selectedRunId: newSelectedRunId
}); });
// If we just loaded from a deep link, clear the params and select it
if (urlTaskId) {
window.history.replaceState({}, document.title, "/");
selectTask(urlTaskId, urlRunId);
}
} catch (error) { } catch (error) {
console.error('Error fetching tasks:', error); console.error('Error fetching tasks:', error);
} }
@ -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;
@ -447,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);
@ -511,17 +649,86 @@ async function checkSession() {
return false; return false;
} }
let isPolling = false; let socket = null;
async function startAutoRefresh() { let reconnectDelay = 1000;
setInterval(async () => {
if (isPolling) return; function connectWebSocket() {
isPolling = true; 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 { try {
await fetchTasks(); const { type, data } = JSON.parse(event.data);
} finally { console.log('WebSocket event:', type, data);
isPolling = false;
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);
} }
}, 3000); };
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() {
@ -552,7 +759,14 @@ async function handleCallback() {
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)}`, {
credentials: 'include' credentials: 'include'
}); });
const data = await response.json(); const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
const text = await response.text();
throw new Error(`Expected JSON but got ${contentType}. Body: ${text.substring(0, 100)}`);
}
if (response.ok) { if (response.ok) {
state.isAuthenticated = true; state.isAuthenticated = true;
@ -578,14 +792,48 @@ logoutBtn.addEventListener('click', () => {
logout(); logout();
}); });
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.activeDashboardTab = btn.dataset.tab;
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() { async function initializeApp() {
const hasSession = await checkSession(); const hasSession = await checkSession();
if (hasSession) { 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 { } else {
showLogin(); showLogin();
} }
@ -597,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,9 +955,9 @@ textarea:focus {
} }
.btn-sm { .btn-sm {
padding: 6px 12px; padding: 6px 16px;
font-size: 12px; font-size: 12px;
width: 100%; width: auto;
justify-content: center; justify-content: center;
} }
@ -885,4 +1044,242 @@ 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

@ -14,6 +14,8 @@ pub struct Config {
pub cookie_secure: bool, pub cookie_secure: bool,
pub agent_max_turns: u32, pub agent_max_turns: u32,
pub agent_max_duration_secs: u64, pub agent_max_duration_secs: u64,
pub vapid_private_key: String,
pub calendar_api_url: String,
} }
impl Config { impl Config {
@ -54,6 +56,12 @@ impl Config {
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(120); .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 { Ok(Config {
database_url, database_url,
port, port,
@ -66,6 +74,8 @@ impl Config {
cookie_secure, cookie_secure,
agent_max_turns, agent_max_turns,
agent_max_duration_secs, 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() {

View file

@ -2,16 +2,21 @@ pub mod api;
pub mod tools; pub mod tools;
use chrono::Utc; use chrono::Utc;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use self::api::{ChatRequest, ChatResponse, Message, Tool}; use self::api::{ChatRequest, ChatResponse, Message, Tool};
pub struct Agent { pub struct Agent {
db: DatabaseConnection,
client: reqwest::Client, client: reqwest::Client,
url: String, url: String,
zen_api_key: Option<String>, zen_api_key: Option<String>,
tavily_api_key: Option<String>, tavily_api_key: Option<String>,
messages: Vec<Message>, calendar_client: Arc<crate::domain::calendar::CalendarClient>,
pub user_sub: Option<String>,
pub messages: Vec<Message>,
tools: Option<Vec<Tool>>, tools: Option<Vec<Tool>>,
logs: String, logs: String,
answer: Option<String>, answer: Option<String>,
@ -21,8 +26,11 @@ use crate::error::{AppError, AppResult};
impl Agent { impl Agent {
pub fn new( pub fn new(
db: DatabaseConnection,
zen_api_key: Option<String>, zen_api_key: Option<String>,
tavily_api_key: Option<String>, tavily_api_key: Option<String>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
user_sub: Option<String>,
initial_message: String, initial_message: String,
) -> AppResult<Self> { ) -> AppResult<Self> {
let intro = format!( let intro = format!(
@ -47,18 +55,42 @@ impl Agent {
}, },
]; ];
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 tools = Some(tools::get_tools());
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60)) .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() .build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?; .map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
Ok(Self { Ok(Self {
db,
client, client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(), url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key, zen_api_key,
tavily_api_key, tavily_api_key,
calendar_client,
user_sub,
messages, messages,
tools, tools,
logs: String::new(), logs: String::new(),
@ -90,6 +122,8 @@ impl Agent {
return Err(AppError::Internal("Agent run exceeded max turns".into())); return Err(AppError::Internal("Agent run exceeded max turns".into()));
} }
tracing::info!("Turn {}", turns);
turns += 1; turns += 1;
let current_role = self let current_role = self
.messages .messages
@ -101,6 +135,59 @@ impl Agent {
turns, current_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 chat_response = self.call_llm().await?;
let assistant_message = chat_response let assistant_message = chat_response
.choices .choices
@ -109,28 +196,39 @@ impl Agent {
.message .message
.clone(); .clone();
tracing::info!("Assistant message: {:#?}", assistant_message);
self.messages.push(assistant_message.clone()); self.messages.push(assistant_message.clone());
if let Some(content) = &assistant_message.content { if let Some(content) = &assistant_message.content {
tracing::info!("Assistant content: {}", content);
if !content.is_empty() { if !content.is_empty() {
self.log(&format!("\nAssistant: {}", content)); self.log(&format!("\nAssistant: {}", content));
} }
} }
if let Some(tool_calls) = assistant_message.tool_calls { 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 { for tool_call in tool_calls {
self.log(&format!("Calling tool: {}", tool_call.function.name)); self.log(&format!("Calling tool: {}", tool_call.function.name));
let (tool_message, is_final, tool_answer) = let (tool_message, is_final, tool_answer) = tools::handle_tool_call(
tools::handle_tool_call(&tool_call, &self.tavily_api_key) tool_call,
.await &self.tavily_api_key,
.map_err(|e| { &self.db,
AppError::Internal(format!("Tool execution failed: {}", e)) &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 { if let Some(ans) = tool_answer {
self.answer = Some(ans); self.answer = Some(ans.clone());
self.log("Task marked as finished by tool."); final_answer = Some(ans);
self.log("Interaction marked as finished by tool.");
} }
if let Some(content) = &tool_message.content { if let Some(content) = &tool_message.content {
@ -139,20 +237,25 @@ impl Agent {
self.messages.push(tool_message); self.messages.push(tool_message);
if is_final { if is_final {
finished = true; is_final_cycle = true;
} }
} }
} else if assistant_message.content.is_some() {
// If assistant just talked without tools, we might be stuck or finished.
// But typically we expect a 'finish' tool call.
self.log("Assistant responded without tool calls.");
// For now we continue unless the assistant explicitly uses a tool to finish,
// or we could add heuristic here if needed.
}
}
self.log("\n--- Execution Finished ---"); if is_final_cycle {
Ok((self.logs.clone(), self.answer.clone())) 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> { async fn call_llm(&self) -> AppResult<ChatResponse> {
@ -162,29 +265,58 @@ impl Agent {
tools: self.tools.clone(), tools: self.tools.clone(),
}; };
let mut request_builder = self.client.post(&self.url).json(&request); let mut request_builder = self
.client
.post(&self.url)
.json(&request)
.timeout(Duration::from_secs(60));
if let Some(key) = &self.zen_api_key { if let Some(key) = &self.zen_api_key {
request_builder = request_builder.header("Authorization", format!("Bearer {}", key)); request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
} }
let response = request_builder.send().await.map_err(AppError::Network)?; 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() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let error_text = response let body_text = response
.text() .text()
.await .await
.unwrap_or_else(|_| "Unknown error".into()); .unwrap_or_else(|_| "Unknown body".into());
return Err(AppError::Internal(format!( let err = format!("API request failed: {} - {}", status, body_text);
"API request failed: {} - {}", tracing::error!("{}", err);
status, error_text return Err(AppError::Internal(err));
)));
} }
response let response_text = response.text().await.map_err(|e| {
.json() let err = format!("Failed to read response text: {}", e);
.await tracing::error!("{}", err);
.map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e))) 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)
})
} }
} }

View file

@ -1,5 +1,10 @@
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall}; use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
};
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
struct GoogleSearchArgs { struct GoogleSearchArgs {
@ -11,6 +16,13 @@ struct FinishArgs {
result: String, result: String,
} }
#[derive(Deserialize)]
struct ListRunsArgs {
from: Option<String>,
to: Option<String>,
task_ids: Option<Vec<String>>,
}
pub fn get_tools() -> Vec<Tool> { pub fn get_tools() -> Vec<Tool> {
vec![ vec![
Tool { Tool {
@ -47,12 +59,94 @@ pub fn get_tools() -> Vec<Tool> {
}), }),
}, },
}, },
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( pub async fn handle_tool_call(
tool_call: &ToolCall, tool_call: &ToolCall,
tavily_api_key: &Option<String>, 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>> { ) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
let mut answer = None; let mut answer = None;
let name = &tool_call.function.name; let name = &tool_call.function.name;
@ -76,6 +170,106 @@ pub async fn handle_tool_call(
answer = Some(result.clone()); answer = Some(result.clone());
(result.clone(), true) (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 { } else {
(format!("Error: Unknown tool {}", name), false) (format!("Error: Unknown tool {}", name), false)
}; };

View file

@ -46,7 +46,10 @@ pub struct JwksVerifier {
impl JwksVerifier { impl JwksVerifier {
pub async fn new(issuer: String, audience: 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()
.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('/')
@ -123,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('/')
@ -190,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(())
}
}

View file

@ -1,3 +1,5 @@
pub mod agent; pub mod agent;
pub mod auth; pub mod auth;
pub mod calendar;
pub mod notifications;
pub mod tasks; 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(())
}
}

View file

@ -1,5 +1,5 @@
use chrono::Utc; use chrono::Utc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@ -8,6 +8,7 @@ use crate::config::Config;
use crate::domain::agent::Agent; use crate::domain::agent::Agent;
use crate::entities::task::Entity as Task; use crate::entities::task::Entity as Task;
use crate::entities::task_run::{self, Entity as TaskRun}; use crate::entities::task_run::{self, Entity as TaskRun};
use crate::entities::{push_subscription, task_subscription};
use crate::scheduler::Scheduler; use crate::scheduler::Scheduler;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -22,7 +23,7 @@ pub struct UpdateTaskRequest {
pub cron: Option<String>, pub cron: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, Clone, Debug)]
pub struct TaskResponse { pub struct TaskResponse {
pub id: Uuid, pub id: Uuid,
pub goal: String, pub goal: String,
@ -31,7 +32,7 @@ pub struct TaskResponse {
pub runs: Vec<TaskRunResponse>, pub runs: Vec<TaskRunResponse>,
} }
#[derive(Serialize)] #[derive(Serialize, Clone, Debug)]
pub struct TaskRunResponse { pub struct TaskRunResponse {
pub id: Uuid, pub id: Uuid,
pub status: String, pub status: String,
@ -40,7 +41,7 @@ pub struct TaskRunResponse {
pub created_at: chrono::DateTime<chrono::FixedOffset>, pub created_at: chrono::DateTime<chrono::FixedOffset>,
} }
#[derive(Serialize)] #[derive(Serialize, Clone, Debug)]
pub struct RecentRunResponse { pub struct RecentRunResponse {
pub id: Uuid, pub id: Uuid,
pub task_id: Uuid, pub task_id: Uuid,
@ -55,11 +56,12 @@ pub async fn execute_agent_run(
db: &DatabaseConnection, db: &DatabaseConnection,
_scheduler: &Arc<Scheduler>, _scheduler: &Arc<Scheduler>,
config: &Arc<Config>, config: &Arc<Config>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
task_id: Uuid, task_id: Uuid,
goal: String, goal: String,
) -> AppResult<TaskResponse> { ) -> AppResult<TaskResponse> {
let run_id = Uuid::new_v4(); let run_id = Uuid::new_v4();
tracing::info!(%task_id, %run_id, "Starting agent execution run"); tracing::info!(%task_id, %run_id, "Starting background agent execution run");
let new_run = task_run::ActiveModel { let new_run = task_run::ActiveModel {
id: Set(run_id), id: Set(run_id),
@ -75,42 +77,106 @@ pub async fn execute_agent_run(
.await .await
.map_err(crate::error::AppError::Database)?; .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( let mut agent = Agent::new(
db.clone(),
config.zen_api_key.clone(), config.zen_api_key.clone(),
config.tavily_api_key.clone(), config.tavily_api_key.clone(),
calendar_client.clone(),
None,
goal.clone(), goal.clone(),
)?; )?;
let (logs, answer, status) = match agent.run(config).await { let db_bg = db.clone();
Ok((logs, answer)) => { let config_bg = config.clone();
tracing::info!(%task_id, %run_id, "Agent execution completed successfully"); let scheduler_bg = _scheduler.clone();
(logs, answer, "completed".to_string()) 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");
} }
Err(e) => {
tracing::error!(%task_id, %run_id, error = %e, "Agent execution failed"); if let Ok(task_response) = get_task_inner(task_id_bg, &db_bg).await {
( let _ = scheduler_bg
format!("Execution failed: {}", e), .tx
None, .send(crate::server::notifications::WsEvent::RunFinished(
"failed".to_string(), 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;
});
}
}
}
}
} }
}; });
let run: task_run::ActiveModel = TaskRun::find_by_id(run_id)
.one(db)
.await
.map_err(crate::error::AppError::Database)?
.ok_or_else(|| crate::error::AppError::NotFound("Run not found after insert".into()))?
.into();
let mut run = run;
run.logs = Set(logs.clone());
run.answer = Set(answer.clone());
run.status = Set(status);
run.update(db)
.await
.map_err(crate::error::AppError::Database)?;
get_task_inner(task_id, db).await get_task_inner(task_id, db).await
} }

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 {}

View file

@ -32,16 +32,20 @@ pub enum AppError {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, error_message) = match self { let (status, error_message) = match &self {
AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err), AppError::NotFound(err) => (StatusCode::NOT_FOUND, err.clone()),
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err), AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err.clone()),
AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()), AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()),
AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err), 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!({ let body = Json(json!({
"error": error_message, "error": error_message,
})); }));

View file

@ -13,12 +13,18 @@ pub struct Scheduler {
db: DatabaseConnection, db: DatabaseConnection,
tasks_to_jobs: DashMap<Uuid, Uuid>, tasks_to_jobs: DashMap<Uuid, Uuid>,
config: Arc<crate::config::Config>, config: Arc<crate::config::Config>,
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,
config: Arc<crate::config::Config>, config: Arc<crate::config::Config>,
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
push_sender: Arc<crate::domain::notifications::push::PushSender>,
) -> AppResult<Self> { ) -> AppResult<Self> {
let scheduler = JobScheduler::new() let scheduler = JobScheduler::new()
.await .await
@ -32,6 +38,9 @@ impl Scheduler {
db, db,
tasks_to_jobs: DashMap::new(), tasks_to_jobs: DashMap::new(),
config, config,
calendar_client,
tx,
push_sender,
}) })
} }
@ -43,12 +52,20 @@ impl Scheduler {
let db = self.db.clone(); let db = self.db.clone();
let config = self.config.clone(); let config = self.config.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 config = config.clone(); let config = config.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, config, task_id).await { if let Err(e) =
Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await
{
tracing::error!("Error in scheduled task {}: {}", task_id, e); tracing::error!("Error in scheduled task {}: {}", task_id, e);
} }
}) })
@ -81,6 +98,9 @@ impl Scheduler {
async fn run_task( async fn run_task(
db: DatabaseConnection, db: DatabaseConnection,
config: Arc<crate::config::Config>, config: Arc<crate::config::Config>,
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,
) -> AppResult<()> { ) -> AppResult<()> {
let task = Task::find_by_id(task_id) let task = Task::find_by_id(task_id)
@ -105,10 +125,23 @@ impl Scheduler {
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
run.insert(&db).await.map_err(AppError::Database)?; 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( let mut agent = Agent::new(
db.clone(),
config.zen_api_key.clone(), config.zen_api_key.clone(),
config.tavily_api_key.clone(), config.tavily_api_key.clone(),
calendar_client.clone(),
None,
task.goal.clone(), task.goal.clone(),
)?; )?;
@ -130,7 +163,7 @@ impl Scheduler {
let run_complete = task_run::ActiveModel { let run_complete = task_run::ActiveModel {
id: Set(run_id), id: Set(run_id),
status: Set(status), status: Set(status.clone()),
logs: Set(logs), logs: Set(logs),
answer: Set(answer), answer: Set(answer),
..Default::default() ..Default::default()
@ -142,6 +175,52 @@ impl Scheduler {
e 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(())

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

View file

@ -1,4 +1,6 @@
pub mod auth; pub mod auth;
pub mod chat;
pub mod notifications;
pub mod tasks; pub mod tasks;
use axum::{ use axum::{
@ -11,7 +13,6 @@ use sea_orm::{Database, DatabaseConnection, EntityTrait};
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::entities::task::Entity as Task;
use crate::scheduler::Scheduler; use crate::scheduler::Scheduler;
use crate::error::AppResult; use crate::error::AppResult;
@ -23,6 +24,8 @@ pub struct AppState {
pub config: Arc<crate::config::Config>, pub config: Arc<crate::config::Config>,
pub verifier: Arc<crate::domain::auth::JwksVerifier>, pub verifier: Arc<crate::domain::auth::JwksVerifier>,
pub authenticator: Arc<crate::domain::auth::Authenticator>, 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<()> { pub async fn start(config: crate::config::Config) -> AppResult<()> {
@ -30,13 +33,32 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
let config = Arc::new(config); 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( let scheduler = Arc::new(
Scheduler::new(db.clone(), config.clone()) Scheduler::new(
.await db.clone(),
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?, 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 // Load existing scheduled tasks
use crate::entities::task::Entity as Task;
let existing_tasks = Task::find() let existing_tasks = Task::find()
.all(&db) .all(&db)
.await .await
@ -47,14 +69,14 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
} }
} }
let (verifier, authenticator) = setup_auth(&config).await?;
let state = Arc::new(AppState { let state = Arc::new(AppState {
db, db,
scheduler, scheduler,
config: config.clone(), config: config.clone(),
verifier, verifier,
authenticator, authenticator,
calendar_client,
tx,
}); });
let app = build_app(state, &config); let app = build_app(state, &config);
@ -120,6 +142,13 @@ fn build_app(state: Arc<AppState>, config: &crate::config::Config) -> Router {
.route("/api/auth/callback", get(auth::auth_callback)) .route("/api/auth/callback", get(auth::auth_callback))
.route("/api/auth/refresh", post(auth::auth_refresh)) .route("/api/auth/refresh", post(auth::auth_refresh))
.route("/api/auth/logout", post(auth::auth_logout)) .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(cors)
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding( .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY, axum::http::header::CONTENT_SECURITY_POLICY,
@ -137,6 +166,22 @@ fn build_app(state: Arc<AppState>, config: &crate::config::Config) -> Router {
.with_state(state) .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 { fn build_cors_layer(config: &crate::config::Config) -> CorsLayer {
let allow_origin = if let Some(origins) = &config.cors_allowed_origins { let allow_origin = if let Some(origins) = &config.cors_allowed_origins {
let values: Vec<HeaderValue> = origins let values: Vec<HeaderValue> = origins

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() }),
))
}

View file

@ -84,7 +84,13 @@ pub async fn create_task(
let _ = state.scheduler.add_task_job(task_id, cron).await; let _ = state.scheduler.add_task_job(task_id, cron).await;
} }
tasks::get_task_inner(task_id, &state.db).await.map(Json) 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( pub async fn rerun_task(
@ -103,6 +109,7 @@ pub async fn rerun_task(
&state.db, &state.db,
&state.scheduler, &state.scheduler,
&state.config, &state.config,
state.calendar_client.clone(),
task.id, task.id,
task.goal, task.goal,
) )
@ -150,7 +157,13 @@ pub async fn update_task(
let _ = state.scheduler.remove_task_job(id).await; let _ = state.scheduler.remove_task_job(id).await;
} }
tasks::get_task_inner(id, &state.db).await.map(Json) 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( pub async fn get_task(