Compare commits
36 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9e976c224 | |||
| 7384bcaf40 | |||
| 9aca4cadf0 | |||
| d3cdcea0df | |||
| 42a78c529d | |||
| 779500d22e | |||
| 0dc8251ec7 | |||
| 0e41f57ce5 | |||
| 6f6827d4d9 | |||
| 37986f8b0b | |||
| e7d1dbe849 | |||
| 8d19873738 | |||
| 91db5861c8 | |||
| cc8fcd682b | |||
| a1dbb328e0 | |||
| f8415c3441 | |||
| 12098aaa8a | |||
| 17261c86b7 | |||
| a5c5a9349f | |||
| 006836b03c | |||
| beab32894b | |||
| bab956387c | |||
| f0b53b67ce | |||
| b44e4ed6f9 | |||
| b9c6c831c1 | |||
| ce5d2c9fb4 | |||
| 7268d49b4a | |||
| 13e17770ca | |||
| 04ece6afb1 | |||
| d90052cfd1 | |||
| 39143e1998 | |||
| 6e1251b136 | |||
| 4dc3d84d38 | |||
| 886d408c3b | |||
| fcad49bf7a | |||
| 844677cc27 |
43 changed files with 5341 additions and 949 deletions
|
|
@ -11,6 +11,31 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
- name: Cache Node.js modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: frontend/node_modules
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
- name: Cache Cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
- name: Cache Cargo target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target/
|
||||||
|
key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-target-
|
||||||
- run: |
|
- run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
/target
|
/target
|
||||||
node_modules
|
node_modules
|
||||||
/frontend/dist
|
/frontend/dist
|
||||||
|
.env
|
||||||
154
AGENTS.md
Normal file
154
AGENTS.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is **Antigravity Agent** — an autonomous AI agent platform built with a Rust backend and a vanilla JS frontend. Users create "tasks" (goals), and the system dispatches an LLM-powered agent to accomplish them. Tasks can be run on-demand or on a cron schedule. Each execution produces logs and a final answer, all persisted to PostgreSQL.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (Vite + Vanilla JS) │
|
||||||
|
│ - OAuth login via Authentik │
|
||||||
|
│ - Dashboard: recent runs, task list, logs, answers │
|
||||||
|
│ - Polls /api/tasks every 3s for live updates │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ /api/*
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Axum HTTP Server (src/server/) │
|
||||||
|
│ - CORS, CSP, rate limiting (1 MB body) │
|
||||||
|
│ - Cookie-based + Bearer token auth │
|
||||||
|
│ - Routes: tasks CRUD, runs, auth (callback/refresh) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Domain Layer (src/domain/) │
|
||||||
|
│ ├─ agent/ Agent loop, LLM API client, tool defs │
|
||||||
|
│ ├─ auth JwksVerifier, Authenticator (OIDC) │
|
||||||
|
│ └─ tasks Task execution, run management │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Scheduler (src/scheduler.rs) │
|
||||||
|
│ - tokio-cron-scheduler for recurring task execution │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Entities (src/entities/) │
|
||||||
|
│ - task, task_run (SeaORM models) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ - Migrations managed via sea-orm-migration │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
├── Cargo.toml # Workspace root (members: ".", "migration")
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Entrypoint: tracing init → server::start()
|
||||||
|
│ ├── config.rs # Config struct loaded from env vars
|
||||||
|
│ ├── error.rs # AppError enum (thiserror) → Axum responses
|
||||||
|
│ ├── scheduler.rs # Cron job scheduler (wraps tokio-cron-scheduler)
|
||||||
|
│ ├── tests.rs # Unit tests for error handling & config
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── agent/
|
||||||
|
│ │ │ ├── mod.rs # Agent struct: agentic loop with turn/time limits
|
||||||
|
│ │ │ ├── api.rs # LLM request/response types, Tavily search client
|
||||||
|
│ │ │ └── tools.rs # Tool definitions (google_search, finish) & dispatch
|
||||||
|
│ │ ├── auth.rs # JwksVerifier (RSA/JWKS), Authenticator (code exchange, refresh)
|
||||||
|
│ │ └── tasks.rs # Task execution logic, response DTOs
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ ├── task.rs # SeaORM entity: tasks table
|
||||||
|
│ │ └── task_run.rs # SeaORM entity: task_runs table (belongs_to task)
|
||||||
|
│ └── server/
|
||||||
|
│ ├── mod.rs # App bootstrap: DB, scheduler, auth, router, CORS
|
||||||
|
│ ├── auth.rs # Auth routes & AuthenticatedUser extractor
|
||||||
|
│ └── tasks.rs # Task CRUD & run endpoints
|
||||||
|
├── migration/
|
||||||
|
│ └── src/ # SeaORM migrations (tasks, answer col, runs table, cron col)
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html # SPA shell with glassmorphism dark theme
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js # All app logic: auth flow, task/run rendering, polling
|
||||||
|
│ │ └── style.css # Styles
|
||||||
|
│ ├── vite.config.js # Dev proxy: /api → localhost:3000
|
||||||
|
│ └── package.json # Deps: vite, marked, dompurify
|
||||||
|
└── .forgejo/workflows/
|
||||||
|
└── pipeline.yaml # CI: build frontend + cargo build → deploy via systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent System
|
||||||
|
|
||||||
|
The agent (`src/domain/agent/`) is a turn-based autonomous loop:
|
||||||
|
|
||||||
|
1. A system prompt is injected with the current date and instructions not to ask the user for clarification.
|
||||||
|
2. The user's goal is sent as the initial message.
|
||||||
|
3. Each turn calls the **Kimi K2.5** model via the Zen API (`https://opencode.ai/zen/v1/chat/completions`).
|
||||||
|
4. The model can invoke tools:
|
||||||
|
- **`google_search`** — web search via the Tavily API.
|
||||||
|
- **`finish`** — signals completion and provides the final answer.
|
||||||
|
5. Tool results are appended to the conversation and the loop continues.
|
||||||
|
6. The loop terminates when `finish` is called, the turn limit is hit (`AGENT_MAX_TURNS`, default 20), or the time limit expires (`AGENT_MAX_DURATION_SECS`, default 120s).
|
||||||
|
|
||||||
|
All turns and tool calls are logged. The final answer (if any) and the full log are persisted to the `task_runs` table.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---------------------------|----------|---------|----------------------------------------------|
|
||||||
|
| `DATABASE_URL` | ✅ | — | PostgreSQL connection string |
|
||||||
|
| `PORT` | | `3000` | HTTP server port |
|
||||||
|
| `ZEN_API_KEY` | | — | API key for Zen/Kimi LLM |
|
||||||
|
| `TAVILY_API_KEY` | | — | API key for Tavily web search |
|
||||||
|
| `AUTHENTIK_ISSUER` | ✅ | — | OIDC issuer URL (Authentik) |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | ✅ | — | OAuth client ID |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | ✅ | — | OAuth client secret |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | | — | Comma-separated allowed origins (or mirror) |
|
||||||
|
| `COOKIE_SECURE` | | `false` | Set `true` for HTTPS-only cookies |
|
||||||
|
| `AGENT_MAX_TURNS` | | `20` | Max LLM turns per agent run |
|
||||||
|
| `AGENT_MAX_DURATION_SECS` | | `120` | Max wall-clock seconds per agent run |
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
All task/run routes require authentication (cookie or Bearer token).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------------------------|-----------------------------------|
|
||||||
|
| GET | `/api/tasks` | List all tasks with their runs |
|
||||||
|
| POST | `/api/tasks` | Create a new task |
|
||||||
|
| GET | `/api/tasks/:id` | Get a single task with runs |
|
||||||
|
| PUT | `/api/tasks/:id` | Update task goal/cron schedule |
|
||||||
|
| POST | `/api/tasks/:id/runs` | Trigger a manual re-run |
|
||||||
|
| GET | `/api/runs/recent` | Latest 50 runs across all tasks |
|
||||||
|
| GET | `/api/auth/session` | Check current session |
|
||||||
|
| GET | `/api/auth/callback` | OAuth code → token exchange |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
|
| POST | `/api/auth/logout` | Clear auth cookies |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**`tasks`** — `id` (UUID PK), `goal` (text), `cron` (text, nullable), `created_at` (timestamptz)
|
||||||
|
|
||||||
|
**`task_runs`** — `id` (UUID PK), `task_id` (FK → tasks, cascade delete), `status` (text), `logs` (text), `answer` (text, nullable), `created_at` (timestamptz)
|
||||||
|
|
||||||
|
Migrations are in `migration/src/` and run automatically on startup via `Migrator::up()`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (from repo root)
|
||||||
|
cargo run # requires DATABASE_URL + Authentik vars
|
||||||
|
|
||||||
|
# Frontend (from frontend/)
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server on :5173, proxies /api to :3000
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI / Deployment
|
||||||
|
|
||||||
|
The Forgejo Actions pipeline (`.forgejo/workflows/pipeline.yaml`) triggers on release publish:
|
||||||
|
|
||||||
|
1. Builds the frontend (`npm install && npm run build`).
|
||||||
|
2. Builds the Rust binary (`cargo build -r`).
|
||||||
|
3. Uploads the binary as a release asset.
|
||||||
|
4. Deploys to the host: copies binary + frontend dist, restarts the `bot` systemd user service.
|
||||||
813
Cargo.lock
generated
813
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -12,8 +12,8 @@ tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
axum = "0.7"
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
tower-http = { version = "0.5", features = ["cors"] }
|
tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
|
||||||
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
||||||
sea-orm-migration = "1.1"
|
sea-orm-migration = "1.1"
|
||||||
uuid = { version = "1.8", features = ["v4", "serde"] }
|
uuid = { version = "1.8", features = ["v4", "serde"] }
|
||||||
|
|
@ -22,4 +22,11 @@ tokio-cron-scheduler = "0.15.1"
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
|
jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
web-push = { version = "0.10.0", features = ["isahc-client"] }
|
||||||
|
isahc = "1.7"
|
||||||
|
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
|
||||||
|
cookie = "0.18"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0a0a0c">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
<title>Antigravity Agent Dashboard</title>
|
<title>Antigravity Agent Dashboard</title>
|
||||||
<link rel="stylesheet" href="/src/style.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
|
|
@ -54,6 +58,14 @@
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<div id="sidebar-overlay" class="sidebar-overlay hidden"></div>
|
||||||
|
|
||||||
|
<button id="menu-toggle" class="menu-toggle" aria-label="Toggle Menu">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div id="empty-state" class="empty-state hidden">
|
<div id="empty-state" class="empty-state hidden">
|
||||||
<div class="empty-icon">⌘</div>
|
<div class="empty-icon">⌘</div>
|
||||||
|
|
@ -63,9 +75,41 @@
|
||||||
|
|
||||||
<div id="dashboard-view" class="dashboard-view">
|
<div id="dashboard-view" class="dashboard-view">
|
||||||
<header class="dashboard-header">
|
<header class="dashboard-header">
|
||||||
<h2>Recent Activity</h2>
|
<h2>Perspective</h2>
|
||||||
<p>Track latest agent executions across all directives.</p>
|
<p>Overview of recent agent directives and live communication.</p>
|
||||||
|
|
||||||
|
<div class="dashboard-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="chat">
|
||||||
|
<span class="tab-icon">💬</span> Quick Chat
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" data-tab="activity">
|
||||||
|
<span class="tab-icon">📊</span> Recent Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-content-grid">
|
||||||
|
<div id="chat-tab-panel" class="tab-panel active">
|
||||||
|
<div id="chat-container" class="chat-container glass">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h3>Neural Link</h3>
|
||||||
|
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear Memory</button>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" class="chat-messages">
|
||||||
|
<!-- Chat messages will be injected here -->
|
||||||
|
<div class="chat-empty-state">
|
||||||
|
<p>Establish connection with the neural assistant.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="chat-form" class="chat-form">
|
||||||
|
<input type="text" id="chat-input" placeholder="Transmit message..." autocomplete="off"
|
||||||
|
required>
|
||||||
|
<button type="submit" id="chat-send-btn" class="btn btn-primary btn-sm">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="activity-tab-panel" class="tab-panel">
|
||||||
<div class="dashboard-content glass">
|
<div class="dashboard-content glass">
|
||||||
<table class="activity-table">
|
<table class="activity-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -81,6 +125,8 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="task-view" class="task-view hidden">
|
<div id="task-view" class="task-view hidden">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
|
|
@ -98,6 +144,9 @@
|
||||||
<button id="rerun-btn" class="btn btn-ghost">
|
<button id="rerun-btn" class="btn btn-ghost">
|
||||||
<span>↻</span> Re-run Task
|
<span>↻</span> Re-run Task
|
||||||
</button>
|
</button>
|
||||||
|
<button id="notify-task-btn" class="btn btn-ghost">
|
||||||
|
<span class="notify-icon">🔔</span> Notify Me
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -175,6 +224,7 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="toast-container"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
21
frontend/public/manifest.json
Normal file
21
frontend/public/manifest.json
Normal 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
119
frontend/public/sw.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
// These should ideally be environment-specific
|
// These should ideally be environment-specific
|
||||||
|
|
@ -16,10 +17,11 @@ const state = {
|
||||||
selectedRunId: null,
|
selectedRunId: null,
|
||||||
currentView: 'dashboard', // 'dashboard' or 'task'
|
currentView: 'dashboard', // 'dashboard' or 'task'
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
token: localStorage.getItem('auth_token'),
|
isAuthenticated: false,
|
||||||
refreshToken: localStorage.getItem('refresh_token')
|
chatMessages: [],
|
||||||
|
activeDashboardTab: 'chat', // 'chat' or 'activity'
|
||||||
|
swRegistration: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM elements
|
// DOM elements
|
||||||
const loginOverlay = document.getElementById('login-overlay');
|
const loginOverlay = document.getElementById('login-overlay');
|
||||||
const callbackOverlay = document.getElementById('callback-overlay');
|
const callbackOverlay = document.getElementById('callback-overlay');
|
||||||
|
|
@ -55,32 +57,71 @@ const schedulePresets = document.getElementById('schedule-presets');
|
||||||
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
|
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
|
||||||
const customCronContainer = document.getElementById('custom-cron-container');
|
const customCronContainer = document.getElementById('custom-cron-container');
|
||||||
const presetBtns = document.querySelectorAll('.btn-preset');
|
const presetBtns = document.querySelectorAll('.btn-preset');
|
||||||
|
const chatMessagesEl = document.getElementById('chat-messages');
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
const clearChatBtn = document.getElementById('clear-chat-btn');
|
||||||
|
const chatSendBtn = document.getElementById('chat-send-btn');
|
||||||
|
const sidebarEl = document.querySelector('.sidebar');
|
||||||
|
const menuToggle = document.getElementById('menu-toggle');
|
||||||
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
|
|
||||||
|
function updateState(newState) {
|
||||||
|
Object.assign(state, newState);
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-icon">${icons[type] || 'ℹ'}</span>
|
||||||
|
<span class="toast-message">${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto remove
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'fadeOut 0.3s forwards';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
|
renderTaskList();
|
||||||
|
|
||||||
|
if (state.currentView === 'dashboard') {
|
||||||
|
fetchRecentRuns();
|
||||||
|
renderChat();
|
||||||
|
} else if (state.selectedTaskId) {
|
||||||
|
const task = state.tasks.find(t => t.id === state.selectedTaskId);
|
||||||
|
if (task) {
|
||||||
|
renderRunHistory(task);
|
||||||
|
showTaskView(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wrapper for fetch to include Authorization header
|
// Wrapper for fetch to include Authorization header
|
||||||
async function fetchWithAuth(url, options = {}) {
|
async function fetchWithAuth(url, options = {}) {
|
||||||
if (!state.token) {
|
let response = await fetch(url, { ...options, credentials: 'include' });
|
||||||
showLogin();
|
|
||||||
throw new Error('Not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
if (response.status === 401) {
|
||||||
...options.headers,
|
|
||||||
'Authorization': `Bearer ${state.token}`
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = await fetch(url, { ...options, headers });
|
|
||||||
|
|
||||||
if (response.status === 401 && state.refreshToken) {
|
|
||||||
// Try to refresh token
|
// Try to refresh token
|
||||||
try {
|
try {
|
||||||
const success = await attemptTokenRefresh();
|
const success = await attemptTokenRefresh();
|
||||||
if (success) {
|
if (success) {
|
||||||
// Retry original request with new token
|
// Retry original request
|
||||||
const newHeaders = {
|
response = await fetch(url, { ...options, credentials: 'include' });
|
||||||
...options.headers,
|
|
||||||
'Authorization': `Bearer ${state.token}`
|
|
||||||
};
|
|
||||||
response = await fetch(url, { ...options, headers: newHeaders });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token refresh failed:', error);
|
console.error('Token refresh failed:', error);
|
||||||
|
|
@ -100,65 +141,59 @@ async function attemptTokenRefresh() {
|
||||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refresh_token: state.refreshToken })
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ refresh_token: '' })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
|
||||||
if (data.access_token) {
|
|
||||||
state.token = data.access_token;
|
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
|
||||||
if (data.refresh_token) {
|
|
||||||
state.refreshToken = data.refresh_token;
|
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during token refresh:', error);
|
console.error('Error during token refresh:', error);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// I'll replace the fetchTasks function and add updateState
|
||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`${API_URL}/tasks`);
|
const response = await fetchWithAuth(`${API_URL}/tasks`);
|
||||||
const newTasks = await response.json();
|
const newTasks = await response.json();
|
||||||
|
|
||||||
// Check if we should follow the latest run (if we were already watching it)
|
// Check for deep link in URL
|
||||||
let shouldFollowLatest = false;
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlTaskId = params.get('taskId');
|
||||||
|
const urlRunId = params.get('runId');
|
||||||
|
|
||||||
|
if (urlTaskId && !state.selectedTaskId) {
|
||||||
|
state.selectedTaskId = urlTaskId;
|
||||||
|
state.selectedRunId = urlRunId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should follow the latest run
|
||||||
|
let newSelectedRunId = state.selectedRunId;
|
||||||
if (state.selectedTaskId) {
|
if (state.selectedTaskId) {
|
||||||
const currentTask = state.tasks.find(t => t.id === state.selectedTaskId);
|
const currentTask = newTasks.find(t => t.id === state.selectedTaskId);
|
||||||
if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
|
if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
|
||||||
const latestRunId = currentTask.runs[currentTask.runs.length - 1].id;
|
// If we don't have a selected run or the runs changed, we might want to update
|
||||||
if (state.selectedRunId === latestRunId) {
|
if (!state.selectedRunId || (state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.length !== currentTask.runs.length)) {
|
||||||
shouldFollowLatest = true;
|
// Only auto-switch if we are "following" the latest
|
||||||
|
const wasFollowingLatest = state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.[0]?.id === state.selectedRunId;
|
||||||
|
if (wasFollowingLatest || !state.selectedRunId) {
|
||||||
|
newSelectedRunId = currentTask.runs[0].id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (!state.selectedRunId) {
|
|
||||||
shouldFollowLatest = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.tasks = newTasks;
|
updateState({
|
||||||
renderTaskList();
|
tasks: newTasks,
|
||||||
|
selectedRunId: newSelectedRunId
|
||||||
|
});
|
||||||
|
|
||||||
// If we are on the dashboard, refresh it too
|
// If we just loaded from a deep link, clear the params and select it
|
||||||
if (state.currentView === 'dashboard') {
|
if (urlTaskId) {
|
||||||
fetchRecentRuns();
|
window.history.replaceState({}, document.title, "/");
|
||||||
}
|
selectTask(urlTaskId, urlRunId);
|
||||||
|
|
||||||
// If a task is selected, update it
|
|
||||||
if (state.selectedTaskId) {
|
|
||||||
const task = state.tasks.find((t) => t.id === state.selectedTaskId);
|
|
||||||
if (task) {
|
|
||||||
if (shouldFollowLatest && task.runs && task.runs.length > 0) {
|
|
||||||
state.selectedRunId = task.runs[task.runs.length - 1].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderRunHistory(task);
|
|
||||||
showTaskView(task);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tasks:', error);
|
console.error('Error fetching tasks:', error);
|
||||||
|
|
@ -177,13 +212,7 @@ async function fetchRecentRuns() {
|
||||||
|
|
||||||
function renderTaskList() {
|
function renderTaskList() {
|
||||||
const sortedTasks = [...state.tasks].sort((a, b) => {
|
const sortedTasks = [...state.tasks].sort((a, b) => {
|
||||||
const aDate = a.runs && a.runs.length > 0
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
? new Date(a.runs[0].created_at)
|
|
||||||
: new Date(a.created_at);
|
|
||||||
const bDate = b.runs && b.runs.length > 0
|
|
||||||
? new Date(b.runs[0].created_at)
|
|
||||||
: new Date(b.created_at);
|
|
||||||
return bDate - aDate;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
taskListEl.innerHTML = sortedTasks
|
taskListEl.innerHTML = sortedTasks
|
||||||
|
|
@ -223,8 +252,8 @@ function selectTask(id, runId = null) {
|
||||||
if (runId) {
|
if (runId) {
|
||||||
state.selectedRunId = runId;
|
state.selectedRunId = runId;
|
||||||
} else if (task.runs && task.runs.length > 0) {
|
} else if (task.runs && task.runs.length > 0) {
|
||||||
// Default to latest run if not specified
|
// Default to latest run if not specified (index 0 is newest)
|
||||||
state.selectedRunId = task.runs[task.runs.length - 1].id;
|
state.selectedRunId = task.runs[0].id;
|
||||||
} else {
|
} else {
|
||||||
state.selectedRunId = null;
|
state.selectedRunId = null;
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +261,24 @@ function selectTask(id, runId = null) {
|
||||||
renderTaskList();
|
renderTaskList();
|
||||||
renderRunHistory(task);
|
renderRunHistory(task);
|
||||||
showTaskView(task);
|
showTaskView(task);
|
||||||
|
|
||||||
|
// Fetch and update subscription status
|
||||||
|
fetch(`${API_URL}/tasks/${id}/subscription`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
const btn = document.getElementById('notify-task-btn');
|
||||||
|
if (data.isSubscribed) {
|
||||||
|
btn.classList.add('notified');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('notified');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch subscription status', err));
|
||||||
|
|
||||||
|
// Close sidebar on mobile after selection
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
closeMobileMenu();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRunHistory(task) {
|
function renderRunHistory(task) {
|
||||||
|
|
@ -264,6 +311,28 @@ function showDashboard() {
|
||||||
|
|
||||||
renderTaskList();
|
renderTaskList();
|
||||||
fetchRecentRuns();
|
fetchRecentRuns();
|
||||||
|
renderDashboardTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboardTabs() {
|
||||||
|
const tabs = document.querySelectorAll('.tab-btn');
|
||||||
|
const panels = document.querySelectorAll('.tab-panel');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
if (tab.dataset.tab === state.activeDashboardTab) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
} else {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panels.forEach(panel => {
|
||||||
|
if (panel.id === `${state.activeDashboardTab}-tab-panel`) {
|
||||||
|
panel.classList.add('active');
|
||||||
|
} else {
|
||||||
|
panel.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboard(recentRuns) {
|
function renderDashboard(recentRuns) {
|
||||||
|
|
@ -354,6 +423,62 @@ function escapeHtml(text) {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
if (!chatMessagesEl) return;
|
||||||
|
|
||||||
|
if (state.chatMessages.length === 0) {
|
||||||
|
chatMessagesEl.innerHTML = `
|
||||||
|
<div class="chat-empty-state">
|
||||||
|
<p>Start a conversation with the assistant.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatMessagesEl.innerHTML = state.chatMessages
|
||||||
|
.map(msg => `
|
||||||
|
<div class="chat-message ${msg.role}">
|
||||||
|
${DOMPurify.sanitize(marked.parse(msg.content || ''))}
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage(text) {
|
||||||
|
const userMessage = { role: 'user', content: text };
|
||||||
|
state.chatMessages.push(userMessage);
|
||||||
|
renderChat();
|
||||||
|
|
||||||
|
chatInput.value = '';
|
||||||
|
chatInput.disabled = true;
|
||||||
|
chatSendBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`${API_URL}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: state.chatMessages })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Chat API failed');
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
state.chatMessages.push(result.message);
|
||||||
|
renderChat();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat error:', error);
|
||||||
|
showToast('Failed to get chat response.', 'error');
|
||||||
|
state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
|
||||||
|
renderChat();
|
||||||
|
} finally {
|
||||||
|
chatInput.disabled = false;
|
||||||
|
chatSendBtn.disabled = false;
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event Listeners
|
// Event Listeners
|
||||||
rerunBtn.addEventListener('click', async () => {
|
rerunBtn.addEventListener('click', async () => {
|
||||||
if (!state.selectedTaskId) return;
|
if (!state.selectedTaskId) return;
|
||||||
|
|
@ -368,9 +493,10 @@ rerunBtn.addEventListener('click', async () => {
|
||||||
state.tasks[index] = updatedTask;
|
state.tasks[index] = updatedTask;
|
||||||
}
|
}
|
||||||
selectTask(updatedTask.id);
|
selectTask(updatedTask.id);
|
||||||
|
showToast('Task rerun successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error running task:', error);
|
console.error('Failed to rerun task:', error);
|
||||||
alert('Failed to run task.');
|
showToast('Failed to rerun task.', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -446,6 +572,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
|
||||||
customCronContainer.classList.toggle('hidden');
|
customCronContainer.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (text) {
|
||||||
|
sendChatMessage(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearChatBtn.addEventListener('click', () => {
|
||||||
|
state.chatMessages = [];
|
||||||
|
renderChat();
|
||||||
|
});
|
||||||
|
|
||||||
cronInput.addEventListener('input', () => {
|
cronInput.addEventListener('input', () => {
|
||||||
// If user types manually, update presets active state
|
// If user types manually, update presets active state
|
||||||
updateScheduleUI(cronInput.value);
|
updateScheduleUI(cronInput.value);
|
||||||
|
|
@ -489,23 +628,107 @@ newTaskForm.addEventListener('submit', async (e) => {
|
||||||
state.isEditing = false;
|
state.isEditing = false;
|
||||||
selectTask(updatedTask.id);
|
selectTask(updatedTask.id);
|
||||||
renderTaskList();
|
renderTaskList();
|
||||||
|
showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating task:', error);
|
console.error('Save task failed:', error);
|
||||||
alert('Failed to execute task. Check console.');
|
showToast('Failed to execute task. Check console.', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let isPolling = false;
|
async function checkSession() {
|
||||||
async function startAutoRefresh() {
|
|
||||||
setInterval(async () => {
|
|
||||||
if (isPolling) return;
|
|
||||||
isPolling = true;
|
|
||||||
try {
|
try {
|
||||||
await fetchTasks();
|
const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' });
|
||||||
} finally {
|
if (response.ok) {
|
||||||
isPolling = false;
|
state.isAuthenticated = true;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}, 3000);
|
} catch (error) {
|
||||||
|
console.error('Session check failed:', error);
|
||||||
|
}
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||||
|
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl);
|
||||||
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
// Initial fetch to sync state
|
||||||
|
fetchTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const { type, data } = JSON.parse(event.data);
|
||||||
|
console.log('WebSocket event:', type, data);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'TaskCreated':
|
||||||
|
state.tasks.unshift(data);
|
||||||
|
renderApp();
|
||||||
|
showToast('New task created', 'success');
|
||||||
|
break;
|
||||||
|
case 'TaskUpdated':
|
||||||
|
case 'RunFinished':
|
||||||
|
const index = state.tasks.findIndex(t => t.id === data.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const wasSelected = state.selectedTaskId === data.id;
|
||||||
|
state.tasks[index] = data;
|
||||||
|
if (wasSelected) {
|
||||||
|
// Update selected run if we were following latest
|
||||||
|
const wasFollowingLatest = state.selectedRunId === state.tasks[index].runs?.[1]?.id || !state.selectedRunId;
|
||||||
|
if (wasFollowingLatest && data.runs && data.runs.length > 0) {
|
||||||
|
state.selectedRunId = data.runs[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.tasks.unshift(data);
|
||||||
|
}
|
||||||
|
renderApp();
|
||||||
|
if (type === 'RunFinished') {
|
||||||
|
showToast(`Task run completed: ${data.goal}`, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'RunStarted':
|
||||||
|
const taskIndex = state.tasks.findIndex(t => t.id === data.task_id);
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
// We don't have the full task update here, but we can update status
|
||||||
|
// For simplicity, we just trigger a fetch or wait for RunFinished
|
||||||
|
// But let's at least show it's running in the UI if selected
|
||||||
|
if (state.tasks[taskIndex].runs) {
|
||||||
|
// Prepend a dummy run or just fetch
|
||||||
|
fetchTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`Task started: ${data.goal}`, 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error handling WebSocket message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected. Reconnecting...');
|
||||||
|
setTimeout(() => {
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
||||||
|
connectWebSocket();
|
||||||
|
}, reconnectDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showLogin() {
|
async function showLogin() {
|
||||||
|
|
@ -514,10 +737,12 @@ async function showLogin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
state.token = null;
|
try {
|
||||||
state.refreshToken = null;
|
await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||||
localStorage.removeItem('auth_token');
|
} catch (error) {
|
||||||
localStorage.removeItem('refresh_token');
|
console.error('Logout failed:', error);
|
||||||
|
}
|
||||||
|
state.isAuthenticated = false;
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,16 +756,20 @@ async function handleCallback() {
|
||||||
loginOverlay.classList.add('hidden');
|
loginOverlay.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`);
|
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, {
|
||||||
const data = await response.json();
|
credentials: 'include'
|
||||||
|
});
|
||||||
if (data.access_token) {
|
const contentType = response.headers.get("content-type");
|
||||||
state.token = data.access_token;
|
let data;
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
if (contentType && contentType.includes("application/json")) {
|
||||||
if (data.refresh_token) {
|
data = await response.json();
|
||||||
state.refreshToken = data.refresh_token;
|
} else {
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
const text = await response.text();
|
||||||
|
throw new Error(`Expected JSON but got ${contentType}. Body: ${text.substring(0, 100)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
state.isAuthenticated = true;
|
||||||
callbackOverlay.classList.add('hidden');
|
callbackOverlay.classList.add('hidden');
|
||||||
appEl.classList.remove('hidden');
|
appEl.classList.remove('hidden');
|
||||||
initializeApp();
|
initializeApp();
|
||||||
|
|
@ -548,8 +777,8 @@ async function handleCallback() {
|
||||||
throw new Error('No access token in response');
|
throw new Error('No access token in response');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth callback failed:', error);
|
console.error('Callback failed:', error);
|
||||||
alert('Authentication failed.');
|
showToast('Authentication failed.', 'error');
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -563,17 +792,51 @@ logoutBtn.addEventListener('click', () => {
|
||||||
logout();
|
logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initializeApp() {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
if (!state.token) {
|
btn.addEventListener('click', () => {
|
||||||
showLogin();
|
state.activeDashboardTab = btn.dataset.tab;
|
||||||
return;
|
renderDashboardTabs();
|
||||||
}
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleMobileMenu() {
|
||||||
|
sidebarEl.classList.toggle('open');
|
||||||
|
menuToggle.classList.toggle('active');
|
||||||
|
sidebarOverlay.classList.toggle('hidden');
|
||||||
|
document.body.style.overflow = sidebarEl.classList.contains('open') ? 'hidden' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileMenu() {
|
||||||
|
sidebarEl.classList.remove('open');
|
||||||
|
menuToggle.classList.remove('active');
|
||||||
|
sidebarOverlay.classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
menuToggle.addEventListener('click', toggleMobileMenu);
|
||||||
|
sidebarOverlay.addEventListener('click', closeMobileMenu);
|
||||||
|
|
||||||
|
async function initializeApp() {
|
||||||
|
const hasSession = await checkSession();
|
||||||
|
|
||||||
|
if (hasSession) {
|
||||||
appEl.classList.remove('hidden');
|
appEl.classList.remove('hidden');
|
||||||
loginOverlay.classList.add('hidden');
|
loginOverlay.classList.add('hidden');
|
||||||
|
|
||||||
|
// Handle deep links from notifications
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const taskId = params.get('taskId');
|
||||||
|
const runId = params.get('runId');
|
||||||
|
if (taskId) {
|
||||||
|
state.selectedTaskId = taskId;
|
||||||
|
state.selectedRunId = runId;
|
||||||
|
}
|
||||||
|
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
startAutoRefresh();
|
connectWebSocket();
|
||||||
|
} else {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for callback on load
|
// Check for callback on load
|
||||||
|
|
@ -582,3 +845,116 @@ if (window.location.pathname === '/callback' || window.location.search.includes(
|
||||||
} else {
|
} else {
|
||||||
initializeApp();
|
initializeApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register Service Worker for PWA
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(reg => {
|
||||||
|
console.log('SW registered', reg);
|
||||||
|
state.swRegistration = reg;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('SW registration failed:', err);
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
console.error('Context is NOT secure. Service Workers require HTTPS or localhost.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupPush() {
|
||||||
|
if (!state.swRegistration) {
|
||||||
|
console.warn('SW registration not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`);
|
||||||
|
const { publicKey } = await vapidResponse.json();
|
||||||
|
|
||||||
|
// Always clear existing subscription to ensure we use latest VAPID key
|
||||||
|
const existingSub = await state.swRegistration.pushManager.getSubscription();
|
||||||
|
if (existingSub) {
|
||||||
|
await existingSub.unsubscribe();
|
||||||
|
console.log('Unsubscribed existing push subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await state.swRegistration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch(`${API_URL}/notifications/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: b64(subscription.getKey('p256dh')),
|
||||||
|
auth: b64(subscription.getKey('auth'))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
console.log('Push registered');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Push registration failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64(buffer) {
|
||||||
|
const binary = String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTaskSubscription(taskId) {
|
||||||
|
const btn = document.getElementById('notify-task-btn');
|
||||||
|
const isNotified = btn.classList.contains('notified');
|
||||||
|
const method = isNotified ? 'DELETE' : 'POST';
|
||||||
|
|
||||||
|
// If trying to enable but no push subscription, try setting it up first (user gesture here)
|
||||||
|
if (!isNotified && 'Notification' in window) {
|
||||||
|
if (Notification.permission !== 'granted') {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
showToast('Notification permission denied', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await state.swRegistration.pushManager.getSubscription();
|
||||||
|
if (!sub) {
|
||||||
|
const success = await setupPush();
|
||||||
|
if (!success) {
|
||||||
|
showToast('Failed to initialize push notifications', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method });
|
||||||
|
btn.classList.toggle('notified');
|
||||||
|
showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to update notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('notify-task-btn').addEventListener('click', () => {
|
||||||
|
if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ body {
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +89,15 @@ body {
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-table {
|
.activity-table {
|
||||||
|
|
@ -124,6 +131,158 @@ body {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-content-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 4px 15px var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty-state {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.activity-table .status-badge {
|
.activity-table .status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
@ -796,8 +955,79 @@ textarea:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
padding: 6px 12px;
|
padding: 6px 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
width: auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast System */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-left: 4px solid var(--status-failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-left: 4px solid var(--status-completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
|
|
@ -815,3 +1045,241 @@ textarea:focus {
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Menu Toggle */
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: calc(16px + env(safe-area-inset-top));
|
||||||
|
right: calc(16px + env(safe-area-inset-right));
|
||||||
|
z-index: 1100;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
box-shadow: 0 4px 15px var(--primary-glow);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bar {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle.active .bar:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle.active .bar:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle.active .bar:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Styles */
|
||||||
|
#notify-task-btn.notified {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notify-task-btn.notified .notify-icon {
|
||||||
|
animation: ring 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: rotate(15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu-toggle {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -320px;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1050;
|
||||||
|
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-view,
|
||||||
|
.task-view {
|
||||||
|
padding: 20px;
|
||||||
|
padding-top: calc(80px + env(safe-area-inset-top));
|
||||||
|
padding-left: max(20px, env(safe-area-inset-left));
|
||||||
|
padding-right: max(20px, env(safe-area-inset-right));
|
||||||
|
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||||
|
height: auto;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-content {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-history {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-details {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-container {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 95%;
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 90%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form input,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force word break for long text in containers */
|
||||||
|
.answer-output,
|
||||||
|
.activity-table td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Date on very small mobile to prevent table overflow */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
|
||||||
|
.activity-table th:last-child,
|
||||||
|
.activity-table td:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent horizontal scroll on the entire app */
|
||||||
|
#app {
|
||||||
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
migration/src/m20260212_000005_notifications.rs
Normal file
115
migration/src/m20260212_000005_notifications.rs
Normal 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,
|
||||||
|
}
|
||||||
593
openapi/calendar/openapi.json
Normal file
593
openapi/calendar/openapi.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
146
src/agent.rs
146
src/agent.rs
|
|
@ -1,146 +0,0 @@
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
use crate::api::{ChatRequest, ChatResponse, Message, Tool};
|
|
||||||
use crate::tools;
|
|
||||||
|
|
||||||
pub struct Agent {
|
|
||||||
client: reqwest::Client,
|
|
||||||
url: String,
|
|
||||||
zen_api_key: Option<String>,
|
|
||||||
tavily_api_key: Option<String>,
|
|
||||||
messages: Vec<Message>,
|
|
||||||
tools: Option<Vec<Tool>>,
|
|
||||||
logs: String,
|
|
||||||
answer: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Agent {
|
|
||||||
pub fn new(
|
|
||||||
zen_api_key: Option<String>,
|
|
||||||
tavily_api_key: Option<String>,
|
|
||||||
initial_message: String,
|
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let intro = format!(
|
|
||||||
"You are an autonomous agent. You have access to tools that can help
|
|
||||||
you achieve your goals. Use them wisely. The user is unable to respond to you
|
|
||||||
so do not ask for clarification and use the
|
|
||||||
answer tool once you to give your final answer. current date is {}",
|
|
||||||
Utc::now().format("%B %d, %Y %H:%M:%S UTC").to_string()
|
|
||||||
);
|
|
||||||
println!("initial_message: {}", intro);
|
|
||||||
let messages = vec![
|
|
||||||
Message {
|
|
||||||
role: "system".to_string(),
|
|
||||||
content: Some(intro),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: None,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: Some(initial_message),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let tools = Some(tools::get_tools());
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
|
|
||||||
zen_api_key,
|
|
||||||
tavily_api_key,
|
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
logs: String::new(),
|
|
||||||
answer: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log(&mut self, message: &str) {
|
|
||||||
println!("{}", message);
|
|
||||||
self.logs.push_str(message);
|
|
||||||
self.logs.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(&mut self) -> Result<(String, Option<String>), Box<dyn std::error::Error>> {
|
|
||||||
let mut file_written = false;
|
|
||||||
|
|
||||||
while !file_written {
|
|
||||||
let request = ChatRequest {
|
|
||||||
model: "kimi-k2.5".to_string(),
|
|
||||||
messages: self.messages.clone(),
|
|
||||||
tools: self.tools.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.log(&format!(
|
|
||||||
"--- Sending request to Zen API (Role: {}) ---",
|
|
||||||
self.messages.last().unwrap().role
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut request_builder = self.client.post(&self.url).json(&request);
|
|
||||||
|
|
||||||
if let Some(key) = &self.zen_api_key {
|
|
||||||
request_builder =
|
|
||||||
request_builder.header("Authorization", format!("Bearer {}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = request_builder.send().await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let error_text = response.text().await?;
|
|
||||||
self.log(&format!("Error: API request failed with status {}", status));
|
|
||||||
self.log(&format!("Error details: {}", error_text));
|
|
||||||
return Err(format!("API request failed: {}", status).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let chat_response: ChatResponse = response.json().await?;
|
|
||||||
let assistant_message = chat_response.choices.get(0).unwrap().message.clone();
|
|
||||||
|
|
||||||
self.messages.push(assistant_message.clone());
|
|
||||||
|
|
||||||
if let Some(content) = &assistant_message.content {
|
|
||||||
if !content.is_empty() {
|
|
||||||
self.log(&format!("\nAssistant response:\n{}\n", content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tool_calls) = assistant_message.tool_calls {
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
let (tool_message, written, tool_answer) =
|
|
||||||
tools::handle_tool_call(&tool_call, &self.tavily_api_key).await?;
|
|
||||||
|
|
||||||
if let Some(ans) = tool_answer {
|
|
||||||
self.answer = Some(ans);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(content) = &tool_message.content {
|
|
||||||
self.log(&format!(
|
|
||||||
"Tool result ({}): {}",
|
|
||||||
tool_call.function.name, content
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.messages.push(tool_message);
|
|
||||||
if written {
|
|
||||||
file_written = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Continue the loop to send tool results back
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No more tool calls from assistant, but we only exit if file was written
|
|
||||||
if !file_written {
|
|
||||||
self.log("--- Assistant didn't use write_file yet. Waiting for next turn... ---");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((self.logs.clone(), self.answer.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
81
src/config.rs
Normal file
81
src/config.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub zen_api_key: Option<String>,
|
||||||
|
pub tavily_api_key: Option<String>,
|
||||||
|
pub authentik_issuer: String,
|
||||||
|
pub authentik_client_id: String,
|
||||||
|
pub authentik_client_secret: String,
|
||||||
|
pub cors_allowed_origins: Option<String>,
|
||||||
|
pub cookie_secure: bool,
|
||||||
|
pub agent_max_turns: u32,
|
||||||
|
pub agent_max_duration_secs: u64,
|
||||||
|
pub vapid_private_key: String,
|
||||||
|
pub calendar_api_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> AppResult<Self> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let database_url = env::var("DATABASE_URL")
|
||||||
|
.map_err(|_| AppError::Config("DATABASE_URL must be set".into()))?;
|
||||||
|
|
||||||
|
let port = env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(3000);
|
||||||
|
|
||||||
|
let zen_api_key = env::var("ZEN_API_KEY").ok();
|
||||||
|
let tavily_api_key = env::var("TAVILY_API_KEY").ok();
|
||||||
|
|
||||||
|
let authentik_issuer = env::var("AUTHENTIK_ISSUER")
|
||||||
|
.map_err(|_| AppError::Config("AUTHENTIK_ISSUER must be set".into()))?;
|
||||||
|
let authentik_client_id = env::var("AUTHENTIK_CLIENT_ID")
|
||||||
|
.map_err(|_| AppError::Config("AUTHENTIK_CLIENT_ID must be set".into()))?;
|
||||||
|
let authentik_client_secret = env::var("AUTHENTIK_CLIENT_SECRET")
|
||||||
|
.map_err(|_| AppError::Config("AUTHENTIK_CLIENT_SECRET must be set".into()))?;
|
||||||
|
|
||||||
|
let cors_allowed_origins = env::var("CORS_ALLOWED_ORIGINS").ok();
|
||||||
|
|
||||||
|
let cookie_secure = env::var("COOKIE_SECURE")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let agent_max_turns = env::var("AGENT_MAX_TURNS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
|
|
||||||
|
let agent_max_duration_secs = env::var("AGENT_MAX_DURATION_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(120);
|
||||||
|
|
||||||
|
let vapid_private_key = env::var("VAPID_PRIVATE_KEY")
|
||||||
|
.map_err(|_| AppError::Config("VAPID_PRIVATE_KEY must be set".into()))?;
|
||||||
|
|
||||||
|
let calendar_api_url =
|
||||||
|
env::var("CALENDAR_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
database_url,
|
||||||
|
port,
|
||||||
|
zen_api_key,
|
||||||
|
tavily_api_key,
|
||||||
|
authentik_issuer,
|
||||||
|
authentik_client_id,
|
||||||
|
authentik_client_secret,
|
||||||
|
cors_allowed_origins,
|
||||||
|
cookie_secure,
|
||||||
|
agent_max_turns,
|
||||||
|
agent_max_duration_secs,
|
||||||
|
vapid_private_key,
|
||||||
|
calendar_api_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
|
@ -29,6 +28,7 @@ pub struct FunctionCall {
|
||||||
pub struct ChatRequest {
|
pub struct ChatRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tools: Option<Vec<Tool>>,
|
pub tools: Option<Vec<Tool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +72,11 @@ pub async fn perform_search(
|
||||||
query: &str,
|
query: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
tracing::info!(query = %query, "Performing Tavily web search");
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.pool_idle_timeout(std::time::Duration::from_secs(60))
|
||||||
.build()?;
|
.build()?;
|
||||||
let response = client
|
let response = client
|
||||||
.post("https://api.tavily.com/search")
|
.post("https://api.tavily.com/search")
|
||||||
|
|
@ -94,6 +97,7 @@ pub async fn perform_search(
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_data: TavilyResponse = response.json().await?;
|
let search_data: TavilyResponse = response.json().await?;
|
||||||
|
tracing::info!("Web search yielded {} results", search_data.results.len());
|
||||||
let mut results_text = String::new();
|
let mut results_text = String::new();
|
||||||
|
|
||||||
for (i, result) in search_data.results.iter().enumerate() {
|
for (i, result) in search_data.results.iter().enumerate() {
|
||||||
322
src/domain/agent/mod.rs
Normal file
322
src/domain/agent/mod.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
pub mod api;
|
||||||
|
pub mod tools;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use self::api::{ChatRequest, ChatResponse, Message, Tool};
|
||||||
|
|
||||||
|
pub struct Agent {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
pub user_sub: Option<String>,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
tools: Option<Vec<Tool>>,
|
||||||
|
logs: String,
|
||||||
|
answer: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
impl Agent {
|
||||||
|
pub fn new(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
user_sub: Option<String>,
|
||||||
|
initial_message: String,
|
||||||
|
) -> AppResult<Self> {
|
||||||
|
let intro = format!(
|
||||||
|
"You are an autonomous agent. You have access to tools that can help
|
||||||
|
you achieve your goals. Use them wisely. The user is unable to respond to you
|
||||||
|
so do not ask for clarification and use the
|
||||||
|
answer tool once you to give your final answer. current date is {}",
|
||||||
|
Utc::now().format("%B %d, %Y %H:%M:%S UTC").to_string()
|
||||||
|
);
|
||||||
|
let messages = vec![
|
||||||
|
Message {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(intro),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: Some(initial_message),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Self::with_messages(
|
||||||
|
db,
|
||||||
|
zen_api_key,
|
||||||
|
tavily_api_key,
|
||||||
|
calendar_client,
|
||||||
|
user_sub,
|
||||||
|
messages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_messages(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
user_sub: Option<String>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
) -> AppResult<Self> {
|
||||||
|
let tools = Some(tools::get_tools());
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.tcp_keepalive(std::time::Duration::from_secs(30))
|
||||||
|
.pool_idle_timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
db,
|
||||||
|
client,
|
||||||
|
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
|
||||||
|
zen_api_key,
|
||||||
|
tavily_api_key,
|
||||||
|
calendar_client,
|
||||||
|
user_sub,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
logs: String::new(),
|
||||||
|
answer: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&mut self, message: &str) {
|
||||||
|
self.logs.push_str(message);
|
||||||
|
self.logs.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
&mut self,
|
||||||
|
config: &crate::config::Config,
|
||||||
|
) -> AppResult<(String, Option<String>)> {
|
||||||
|
let mut finished = false;
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let max_duration = Duration::from_secs(config.agent_max_duration_secs);
|
||||||
|
let max_turns = config.agent_max_turns;
|
||||||
|
let mut turns = 0;
|
||||||
|
|
||||||
|
while !finished {
|
||||||
|
if start_time.elapsed() > max_duration {
|
||||||
|
return Err(AppError::Internal("Agent run timed out".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if turns >= max_turns {
|
||||||
|
return Err(AppError::Internal("Agent run exceeded max turns".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Turn {}", turns);
|
||||||
|
|
||||||
|
turns += 1;
|
||||||
|
let current_role = self
|
||||||
|
.messages
|
||||||
|
.last()
|
||||||
|
.map(|m| m.role.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
self.log(&format!(
|
||||||
|
"\n[Turn {}] Sending request (Last role: {})",
|
||||||
|
turns, current_role
|
||||||
|
));
|
||||||
|
|
||||||
|
let assistant_message =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(180), self.execute_turn()).await {
|
||||||
|
Ok(res) => res?,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!("Agent execution turn timed out after 180s");
|
||||||
|
return Err(AppError::Internal("Agent execution turn timed out".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tool_calls) = &assistant_message.tool_calls {
|
||||||
|
tracing::info!("Assistant tool calls: {:#?}", tool_calls);
|
||||||
|
if tool_calls.iter().any(|tc| tc.function.name == "answer") {
|
||||||
|
finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.answer.is_some() {
|
||||||
|
tracing::info!("Answer: {}", self.answer.as_ref().unwrap());
|
||||||
|
finished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !finished {
|
||||||
|
self.messages.push(Message {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(
|
||||||
|
"continue, use the finish tool to submit your final answer".to_string(),
|
||||||
|
),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Finished turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.log("\n--- Execution Finished ---");
|
||||||
|
Ok((self.logs.clone(), self.answer.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_turn(&mut self) -> AppResult<Message> {
|
||||||
|
let max_sub_turns = 20;
|
||||||
|
let mut sub_turns = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tracing::info!("Sub turn {}", sub_turns);
|
||||||
|
|
||||||
|
sub_turns += 1;
|
||||||
|
if sub_turns > max_sub_turns {
|
||||||
|
return Err(AppError::Internal(
|
||||||
|
"Interaction cycle turn limit exceeded".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_response = self.call_llm().await?;
|
||||||
|
let assistant_message = chat_response
|
||||||
|
.choices
|
||||||
|
.get(0)
|
||||||
|
.ok_or_else(|| AppError::Internal("Missing assistant response".into()))?
|
||||||
|
.message
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
tracing::info!("Assistant message: {:#?}", assistant_message);
|
||||||
|
|
||||||
|
self.messages.push(assistant_message.clone());
|
||||||
|
|
||||||
|
if let Some(content) = &assistant_message.content {
|
||||||
|
tracing::info!("Assistant content: {}", content);
|
||||||
|
if !content.is_empty() {
|
||||||
|
self.log(&format!("\nAssistant: {}", content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tool_calls) = &assistant_message.tool_calls {
|
||||||
|
tracing::info!("Assistant tool calls: {:#?}", tool_calls);
|
||||||
|
let mut is_final_cycle = false;
|
||||||
|
let mut final_answer = None;
|
||||||
|
|
||||||
|
for tool_call in tool_calls {
|
||||||
|
self.log(&format!("Calling tool: {}", tool_call.function.name));
|
||||||
|
|
||||||
|
let (tool_message, is_final, tool_answer) = tools::handle_tool_call(
|
||||||
|
tool_call,
|
||||||
|
&self.tavily_api_key,
|
||||||
|
&self.db,
|
||||||
|
&self.calendar_client,
|
||||||
|
self.user_sub.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Tool execution failed: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(ans) = tool_answer {
|
||||||
|
self.answer = Some(ans.clone());
|
||||||
|
final_answer = Some(ans);
|
||||||
|
self.log("Interaction marked as finished by tool.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content) = &tool_message.content {
|
||||||
|
self.log(&format!("Tool result: {}", content));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.messages.push(tool_message);
|
||||||
|
if is_final {
|
||||||
|
is_final_cycle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_final_cycle {
|
||||||
|
tracing::info!("Final answer: {}", final_answer.as_ref().unwrap());
|
||||||
|
return Ok(Message {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: final_answer.or(assistant_message.content),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(assistant_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_llm(&self) -> AppResult<ChatResponse> {
|
||||||
|
let request = ChatRequest {
|
||||||
|
model: "kimi-k2.5".to_string(),
|
||||||
|
messages: self.messages.clone(),
|
||||||
|
tools: self.tools.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request_builder = self
|
||||||
|
.client
|
||||||
|
.post(&self.url)
|
||||||
|
.json(&request)
|
||||||
|
.timeout(Duration::from_secs(60));
|
||||||
|
|
||||||
|
if let Some(key) = &self.zen_api_key {
|
||||||
|
request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let response = request_builder.send().await.map_err(|e| {
|
||||||
|
let duration = start.elapsed();
|
||||||
|
let is_timeout = e.is_timeout();
|
||||||
|
let is_connect = e.is_connect();
|
||||||
|
tracing::error!(
|
||||||
|
"Network error after {:?} during LLM call (Timeout: {}, Connect: {}): {:?}",
|
||||||
|
duration,
|
||||||
|
is_timeout,
|
||||||
|
is_connect,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
AppError::Network(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let duration = start.elapsed();
|
||||||
|
tracing::info!("LLM request completed in {:?}", duration);
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown body".into());
|
||||||
|
let err = format!("API request failed: {} - {}", status, body_text);
|
||||||
|
tracing::error!("{}", err);
|
||||||
|
return Err(AppError::Internal(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_text = response.text().await.map_err(|e| {
|
||||||
|
let err = format!("Failed to read response text: {}", e);
|
||||||
|
tracing::error!("{}", err);
|
||||||
|
AppError::Internal(err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
serde_json::from_str(&response_text).map_err(|e| {
|
||||||
|
let err = format!(
|
||||||
|
"Failed to parse LLM response: {} | Raw Body: {}",
|
||||||
|
e, response_text
|
||||||
|
);
|
||||||
|
tracing::error!("{}", err);
|
||||||
|
AppError::Internal(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
287
src/domain/agent/tools.rs
Normal file
287
src/domain/agent/tools.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
|
||||||
|
use sea_orm::{
|
||||||
|
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GoogleSearchArgs {
|
||||||
|
query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FinishArgs {
|
||||||
|
result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ListRunsArgs {
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
task_ids: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_tools() -> Vec<Tool> {
|
||||||
|
vec![
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "google_search".to_string(),
|
||||||
|
description: "Search the web for information".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "finish".to_string(),
|
||||||
|
description: "Finish the task and provide a final answer".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"result": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The final detailed answer to the task"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["result"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "list_tasks".to_string(),
|
||||||
|
description: "List all existing tasks and their goals".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "list_runs".to_string(),
|
||||||
|
description: "List task runs with optional filters for date and task IDs"
|
||||||
|
.to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 date string to filter runs from"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 date string to filter runs to"
|
||||||
|
},
|
||||||
|
"task_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "format": "uuid" },
|
||||||
|
"description": "List of task IDs to filter runs for"
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "calendar_list_events".to_string(),
|
||||||
|
description: "List calendar events".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"upcoming": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, only upcoming events will be listed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "calendar_create_event".to_string(),
|
||||||
|
description: "Create a new calendar event".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the event"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Start time in ISO 8601 format (e.g., 2023-10-27T10:00:00Z)"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "End time in ISO 8601 format (e.g., 2023-10-27T11:00:00Z)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "from", "to"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_tool_call(
|
||||||
|
tool_call: &ToolCall,
|
||||||
|
tavily_api_key: &Option<String>,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
calendar: &Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
user_sub: Option<&str>,
|
||||||
|
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
||||||
|
let mut answer = None;
|
||||||
|
let name = &tool_call.function.name;
|
||||||
|
|
||||||
|
let (content, written) = if name == "google_search" {
|
||||||
|
let args: GoogleSearchArgs = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let query = &args.query;
|
||||||
|
|
||||||
|
let search_result = if let Some(key) = tavily_api_key {
|
||||||
|
match api::perform_search(query, key).await {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(e) => format!("Search error: {}", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
|
||||||
|
};
|
||||||
|
(search_result, false)
|
||||||
|
} else if name == "finish" {
|
||||||
|
let args: FinishArgs = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let result = &args.result;
|
||||||
|
|
||||||
|
answer = Some(result.clone());
|
||||||
|
(result.clone(), true)
|
||||||
|
} else if name == "list_tasks" {
|
||||||
|
tracing::info!("Listing tasks from database");
|
||||||
|
use crate::entities::task;
|
||||||
|
let tasks = task::Entity::find()
|
||||||
|
.order_by_desc(task::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut out = String::from("Tasks:\n");
|
||||||
|
for t in tasks {
|
||||||
|
out.push_str(&format!("- ID: {}, Goal: {}\n", t.id, t.goal));
|
||||||
|
}
|
||||||
|
(out, false)
|
||||||
|
} else if name == "list_runs" {
|
||||||
|
use crate::entities::task_run;
|
||||||
|
tracing::info!(
|
||||||
|
"Listing runs from database, args: {}",
|
||||||
|
tool_call.function.arguments
|
||||||
|
);
|
||||||
|
let args: ListRunsArgs = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Listing runs from database with filters: from={:?}, to={:?}, task_ids={:?}",
|
||||||
|
args.from,
|
||||||
|
args.to,
|
||||||
|
args.task_ids
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = task_run::Entity::find();
|
||||||
|
|
||||||
|
let mut condition = Condition::all();
|
||||||
|
|
||||||
|
if let Some(from_str) = args.from {
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&from_str) {
|
||||||
|
condition = condition.add(task_run::Column::CreatedAt.gte(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(to_str) = args.to {
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&to_str) {
|
||||||
|
condition = condition.add(task_run::Column::CreatedAt.lte(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id_strs) = args.task_ids {
|
||||||
|
let mut valid_ids = Vec::new();
|
||||||
|
for id_str in id_strs {
|
||||||
|
match id_str.parse::<Uuid>() {
|
||||||
|
Ok(uuid) => valid_ids.push(uuid),
|
||||||
|
Err(e) => tracing::warn!("Skipping invalid UUID '{}' from LLM: {}", id_str, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid_ids.is_empty() {
|
||||||
|
condition = condition.add(task_run::Column::TaskId.is_in(valid_ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let runs = query
|
||||||
|
.filter(condition)
|
||||||
|
.order_by_desc(task_run::Column::CreatedAt)
|
||||||
|
.limit(20)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut out = String::from("Recent Runs:\n");
|
||||||
|
for r in runs {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- ID: {}, Task ID: {}, Status: {}, Created At: {}, Answer: {:?}\n",
|
||||||
|
r.id, r.task_id, r.status, r.created_at, r.answer
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(out, false)
|
||||||
|
} else if name == "calendar_list_events" {
|
||||||
|
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let upcoming = args["upcoming"].as_bool();
|
||||||
|
match calendar
|
||||||
|
.list_events(user_sub.map(|s| s.to_string()), upcoming)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(events) => {
|
||||||
|
tracing::info!("{:#?}", events);
|
||||||
|
(serde_json::to_string(&events)?, false)
|
||||||
|
}
|
||||||
|
Err(e) => (format!("Error listing events: {}", e), false),
|
||||||
|
}
|
||||||
|
} else if name == "calendar_create_event" {
|
||||||
|
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let name_val = args["name"].as_str().unwrap_or_default();
|
||||||
|
let from_val = args["from"].as_str().unwrap_or_default();
|
||||||
|
let to_val = args["to"].as_str().unwrap_or_default();
|
||||||
|
match calendar
|
||||||
|
.create_event(user_sub.map(|s| s.to_string()), name_val, from_val, to_val)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(event) => (
|
||||||
|
format!("Event created: {}", serde_json::to_string(&event)?),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Err(e) => (format!("Error creating event: {}", e), false),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(format!("Error: Unknown tool {}", name), false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Message {
|
||||||
|
role: "tool".to_string(),
|
||||||
|
content: Some(content),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: Some(tool_call.id.clone()),
|
||||||
|
},
|
||||||
|
written,
|
||||||
|
answer,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,17 @@ pub struct Claims {
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: Audience,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Audience {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
struct Jwk {
|
struct Jwk {
|
||||||
#[serde(rename = "kty")]
|
#[serde(rename = "kty")]
|
||||||
_kty: String,
|
_kty: String,
|
||||||
|
|
@ -31,15 +38,18 @@ struct Jwks {
|
||||||
|
|
||||||
pub struct JwksVerifier {
|
pub struct JwksVerifier {
|
||||||
issuer: String,
|
issuer: String,
|
||||||
|
audience: String,
|
||||||
jwks_uri: String,
|
jwks_uri: String,
|
||||||
keys: Arc<RwLock<Vec<Jwk>>>,
|
keys: Arc<RwLock<Vec<Jwk>>>,
|
||||||
client: Client,
|
client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwksVerifier {
|
impl JwksVerifier {
|
||||||
pub async fn new(issuer: String) -> Result<Self, Box<dyn std::error::Error>> {
|
pub async fn new(issuer: String, audience: String) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let client = Client::new();
|
let client = Client::builder()
|
||||||
// Authentik OIDC discovery
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
let discovery_url = format!(
|
let discovery_url = format!(
|
||||||
"{}/.well-known/openid-configuration",
|
"{}/.well-known/openid-configuration",
|
||||||
issuer.trim_end_matches('/')
|
issuer.trim_end_matches('/')
|
||||||
|
|
@ -53,6 +63,7 @@ impl JwksVerifier {
|
||||||
|
|
||||||
let verifier = Self {
|
let verifier = Self {
|
||||||
issuer,
|
issuer,
|
||||||
|
audience,
|
||||||
jwks_uri,
|
jwks_uri,
|
||||||
keys: Arc::new(RwLock::new(Vec::new())),
|
keys: Arc::new(RwLock::new(Vec::new())),
|
||||||
client,
|
client,
|
||||||
|
|
@ -73,18 +84,29 @@ impl JwksVerifier {
|
||||||
let header = decode_header(token)?;
|
let header = decode_header(token)?;
|
||||||
let kid = header.kid.ok_or("Missing kid in token header")?;
|
let kid = header.kid.ok_or("Missing kid in token header")?;
|
||||||
|
|
||||||
|
let jwk = {
|
||||||
let keys = self.keys.read().await;
|
let keys = self.keys.read().await;
|
||||||
let jwk = keys
|
keys.iter().find(|k| k.kid == kid).cloned()
|
||||||
.iter()
|
};
|
||||||
|
|
||||||
|
let jwk = match jwk {
|
||||||
|
Some(key) => key,
|
||||||
|
None => {
|
||||||
|
self.refresh_keys().await?;
|
||||||
|
let keys = self.keys.read().await;
|
||||||
|
keys.iter()
|
||||||
.find(|k| k.kid == kid)
|
.find(|k| k.kid == kid)
|
||||||
.ok_or("Key not found in JWKS")?;
|
.cloned()
|
||||||
|
.ok_or("Key not found in JWKS")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
|
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
|
||||||
|
|
||||||
let mut validation = Validation::new(Algorithm::RS256);
|
let mut validation = Validation::new(Algorithm::RS256);
|
||||||
validation.set_issuer(&[self.issuer.clone()]);
|
validation.set_issuer(&[self.issuer.clone()]);
|
||||||
// Aud validation might need careful config, usually it's the client_id
|
validation.set_audience(&[self.audience.clone()]);
|
||||||
validation.validate_aud = false;
|
validation.validate_aud = true;
|
||||||
|
|
||||||
let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
|
let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
|
|
@ -104,7 +126,10 @@ impl Authenticator {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
client_secret: String,
|
client_secret: String,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let client = Client::new();
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
let discovery_url = format!(
|
let discovery_url = format!(
|
||||||
"{}/.well-known/openid-configuration",
|
"{}/.well-known/openid-configuration",
|
||||||
issuer.trim_end_matches('/')
|
issuer.trim_end_matches('/')
|
||||||
|
|
@ -171,4 +196,27 @@ impl Authenticator {
|
||||||
|
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn client_credentials(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||||
|
let params = [
|
||||||
|
("grant_type", "client_credentials"),
|
||||||
|
("client_id", &self.client_id),
|
||||||
|
("client_secret", &self.client_secret),
|
||||||
|
("scope", scope),
|
||||||
|
];
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.post(&self.token_url)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
298
src/domain/calendar/mod.rs
Normal file
298
src/domain/calendar/mod.rs
Normal 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(¶ms.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(method = "GET", %url, "Sending Calendar API request");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Network)?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
tracing::info!(%status, %url, "Received Calendar API response");
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Failed to list events: {} - {}",
|
||||||
|
status, error_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = res
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()));
|
||||||
|
tracing::info!(%status, %url, body = ?body, "Received Calendar API response");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_event(
|
||||||
|
&self,
|
||||||
|
user_sub: Option<String>,
|
||||||
|
name: &str,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> AppResult<CalendarEvent> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/service/v1/events", self.base_url);
|
||||||
|
|
||||||
|
let request = CreateEventRequest {
|
||||||
|
name: name.to_string(),
|
||||||
|
from: from.to_string(),
|
||||||
|
to: to.to_string(),
|
||||||
|
user_sub,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(method = "POST", %url, "Sending Calendar API request");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Network)?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
tracing::info!(%status, %url, "Received Calendar API response");
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Failed to create event: {} - {}",
|
||||||
|
status, error_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get_event(&self, id: i32) -> AppResult<CalendarEvent> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/service/v1/events/{}", self.base_url, id);
|
||||||
|
tracing::info!(method = "GET", %url, "Sending Calendar API request");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Network)?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
tracing::info!(%status, %url, "Received Calendar API response");
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Failed to get event: {} - {}",
|
||||||
|
status, error_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn update_event(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
user_sub: Option<String>,
|
||||||
|
name: &str,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> AppResult<CalendarEvent> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/service/v1/events/{}", self.base_url, id);
|
||||||
|
|
||||||
|
let request = CreateEventRequest {
|
||||||
|
name: name.to_string(),
|
||||||
|
from: from.to_string(),
|
||||||
|
to: to.to_string(),
|
||||||
|
user_sub,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(method = "PUT", %url, "Sending Calendar API request");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.put(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Network)?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
tracing::info!(%status, %url, "Received Calendar API response");
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Failed to update event: {} - {}",
|
||||||
|
status, error_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn delete_event(&self, id: i32) -> AppResult<()> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/service/v1/events/{}", self.base_url, id);
|
||||||
|
|
||||||
|
tracing::info!(method = "DELETE", %url, "Sending Calendar API request");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Network)?;
|
||||||
|
|
||||||
|
let status = res.status();
|
||||||
|
tracing::info!(%status, %url, "Received Calendar API response");
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!(%status, %url, body = %error_body, "Calendar API request failed");
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Failed to delete event: {} - {}",
|
||||||
|
status, error_body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/domain/mod.rs
Normal file
5
src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod agent;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod tasks;
|
||||||
1
src/domain/notifications/mod.rs
Normal file
1
src/domain/notifications/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod push;
|
||||||
113
src/domain/notifications/push.rs
Normal file
113
src/domain/notifications/push.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use web_push::*;
|
||||||
|
|
||||||
|
pub struct PushSender {
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct PushSubscription {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub p256dh: String,
|
||||||
|
pub auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_pem(input: &str) -> String {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.contains("-----BEGIN") {
|
||||||
|
return input.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.starts_with("MHc") {
|
||||||
|
format!(
|
||||||
|
"-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----",
|
||||||
|
input
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----",
|
||||||
|
input
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PushSender {
|
||||||
|
pub fn new(private_key_pem: &str) -> AppResult<Self> {
|
||||||
|
let pem = ensure_pem(private_key_pem);
|
||||||
|
// Validate key immediately to catch config errors early
|
||||||
|
let _ =
|
||||||
|
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&pem)).map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Invalid VAPID private key: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self { private_key: pem })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_public_key(&self) -> AppResult<Vec<u8>> {
|
||||||
|
let builder =
|
||||||
|
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key))
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(builder.get_public_key())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_notification(
|
||||||
|
&self,
|
||||||
|
subscription: &PushSubscription,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
task_id: Option<Uuid>,
|
||||||
|
run_id: Option<Uuid>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let subscription_info = SubscriptionInfo::new(
|
||||||
|
subscription.endpoint.clone(),
|
||||||
|
subscription.p256dh.clone(),
|
||||||
|
subscription.auth.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let builder =
|
||||||
|
VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key))
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut builder = builder.add_sub_info(&subscription_info);
|
||||||
|
builder.add_claim("sub", "mailto:pavel@flegr.me");
|
||||||
|
|
||||||
|
let vapid_signature = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||||
|
|
||||||
|
builder.set_vapid_signature(vapid_signature);
|
||||||
|
|
||||||
|
let payload = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"task_id": task_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
}))
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
builder.set_payload(ContentEncoding::Aes128Gcm, &payload);
|
||||||
|
|
||||||
|
let message = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let client = IsahcWebPushClient::new()
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
client.send(message).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to send push notification: {}", e);
|
||||||
|
crate::error::AppError::Internal(e.to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Push notification sent successfully to {}",
|
||||||
|
subscription.endpoint
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src/domain/tasks.rs
Normal file
214
src/domain/tasks.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::domain::agent::Agent;
|
||||||
|
use crate::entities::task::Entity as Task;
|
||||||
|
use crate::entities::task_run::{self, Entity as TaskRun};
|
||||||
|
use crate::entities::{push_subscription, task_subscription};
|
||||||
|
use crate::scheduler::Scheduler;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateTaskRequest {
|
||||||
|
pub goal: String,
|
||||||
|
pub cron: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateTaskRequest {
|
||||||
|
pub goal: String,
|
||||||
|
pub cron: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
pub struct TaskResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub goal: String,
|
||||||
|
pub cron: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
pub runs: Vec<TaskRunResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
pub struct TaskRunResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
pub logs: String,
|
||||||
|
pub answer: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
pub struct RecentRunResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub task_id: Uuid,
|
||||||
|
pub goal: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
|
||||||
|
pub async fn execute_agent_run(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
_scheduler: &Arc<Scheduler>,
|
||||||
|
config: &Arc<Config>,
|
||||||
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
task_id: Uuid,
|
||||||
|
goal: String,
|
||||||
|
) -> AppResult<TaskResponse> {
|
||||||
|
let run_id = Uuid::new_v4();
|
||||||
|
tracing::info!(%task_id, %run_id, "Starting background agent execution run");
|
||||||
|
|
||||||
|
let new_run = task_run::ActiveModel {
|
||||||
|
id: Set(run_id),
|
||||||
|
task_id: Set(task_id),
|
||||||
|
status: Set("running".to_string()),
|
||||||
|
logs: Set(String::new()),
|
||||||
|
answer: Set(None),
|
||||||
|
created_at: Set(Utc::now().into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
new_run
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
|
||||||
|
let _ = _scheduler
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::RunStarted(
|
||||||
|
RecentRunResponse {
|
||||||
|
id: run_id,
|
||||||
|
task_id,
|
||||||
|
goal: goal.clone(),
|
||||||
|
status: "running".to_string(),
|
||||||
|
created_at: Utc::now().into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut agent = Agent::new(
|
||||||
|
db.clone(),
|
||||||
|
config.zen_api_key.clone(),
|
||||||
|
config.tavily_api_key.clone(),
|
||||||
|
calendar_client.clone(),
|
||||||
|
None,
|
||||||
|
goal.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let db_bg = db.clone();
|
||||||
|
let config_bg = config.clone();
|
||||||
|
let scheduler_bg = _scheduler.clone();
|
||||||
|
let task_id_bg = task_id;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (logs, answer, status) = match agent.run(&config_bg).await {
|
||||||
|
Ok((logs, answer)) => {
|
||||||
|
tracing::info!(task_id = %task_id_bg, run_id = %run_id, "Agent execution completed successfully");
|
||||||
|
(logs, answer, "completed".to_string())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Agent execution failed");
|
||||||
|
(
|
||||||
|
format!("Execution failed: {}", e),
|
||||||
|
None,
|
||||||
|
"failed".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let run_update = task_run::ActiveModel {
|
||||||
|
id: Set(run_id),
|
||||||
|
logs: Set(logs),
|
||||||
|
answer: Set(answer),
|
||||||
|
status: Set(status.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = run_update.update(&db_bg).await {
|
||||||
|
tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Failed to update run record");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(task_response) = get_task_inner(task_id_bg, &db_bg).await {
|
||||||
|
let _ = scheduler_bg
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::RunFinished(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Send Push Notifications to subscribers
|
||||||
|
if let Ok(subscriptions) = task_subscription::Entity::find()
|
||||||
|
.filter(task_subscription::Column::TaskId.eq(task_id_bg))
|
||||||
|
.all(&db_bg)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for sub in subscriptions {
|
||||||
|
if let Ok(push_subs) = push_subscription::Entity::find()
|
||||||
|
.filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone()))
|
||||||
|
.all(&db_bg)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for push_sub in push_subs {
|
||||||
|
let sender = scheduler_bg.push_sender.clone();
|
||||||
|
let goal = task_response.goal.clone();
|
||||||
|
let status_bg = status.clone();
|
||||||
|
let sub_data = crate::domain::notifications::push::PushSubscription {
|
||||||
|
endpoint: push_sub.endpoint,
|
||||||
|
p256dh: push_sub.p256dh,
|
||||||
|
auth: push_sub.auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = sender
|
||||||
|
.send_notification(
|
||||||
|
&sub_data,
|
||||||
|
&format!("Task Completed: {}", status_bg),
|
||||||
|
&goal,
|
||||||
|
Some(task_id_bg),
|
||||||
|
Some(run_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
get_task_inner(task_id, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult<TaskResponse> {
|
||||||
|
let results = Task::find_by_id(id)
|
||||||
|
.find_with_related(TaskRun)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
|
||||||
|
let (t, mut runs) = results
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".into()))?;
|
||||||
|
|
||||||
|
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
|
||||||
|
Ok(TaskResponse {
|
||||||
|
id: t.id,
|
||||||
|
goal: t.goal,
|
||||||
|
cron: t.cron,
|
||||||
|
created_at: t.created_at,
|
||||||
|
runs: runs
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TaskRunResponse {
|
||||||
|
id: r.id,
|
||||||
|
status: r.status,
|
||||||
|
logs: r.logs,
|
||||||
|
answer: r.answer,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
19
src/entities/push_subscription.rs
Normal file
19
src/entities/push_subscription.rs
Normal 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 {}
|
||||||
32
src/entities/task_subscription.rs
Normal file
32
src/entities/task_subscription.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "task_subscriptions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_sub: String,
|
||||||
|
pub task_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::task::Entity",
|
||||||
|
from = "Column::TaskId",
|
||||||
|
to = "super::task::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Task,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::task::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Task.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
57
src/error.rs
Normal file
57
src/error.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sea_orm::DbErr),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Internal server error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
Network(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("Invalid request: {0}")]
|
||||||
|
InvalidRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, error_message) = match &self {
|
||||||
|
AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||||
|
AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
|
||||||
|
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err.clone()),
|
||||||
|
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err.clone()),
|
||||||
|
AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()),
|
||||||
|
AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()),
|
||||||
|
AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if status.is_server_error() || status.is_client_error() {
|
||||||
|
tracing::error!(%status, error = %self, "AppError converted to response");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = Json(json!({
|
||||||
|
"error": error_message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
24
src/main.rs
24
src/main.rs
|
|
@ -1,16 +1,28 @@
|
||||||
mod agent;
|
mod config;
|
||||||
mod api;
|
mod domain;
|
||||||
mod auth;
|
|
||||||
mod entities;
|
mod entities;
|
||||||
|
mod error;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod server;
|
mod server;
|
||||||
mod tools;
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "bot=info,axum=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
server::start(&db_url).await?;
|
info!("Starting Antigravity Agent...");
|
||||||
|
|
||||||
|
let config = config::Config::from_env()?;
|
||||||
|
|
||||||
|
server::start(config).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
src/scheduler.rs
184
src/scheduler.rs
|
|
@ -1,8 +1,10 @@
|
||||||
use crate::agent::Agent;
|
use crate::domain::agent::Agent;
|
||||||
use crate::entities::task::Entity as Task;
|
use crate::entities::task::Entity as Task;
|
||||||
use crate::entities::task_run;
|
use crate::entities::task_run;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use sea_orm::{DatabaseConnection, EntityTrait, Set};
|
use sea_orm::{DatabaseConnection, EntityTrait, Set};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -10,78 +12,107 @@ pub struct Scheduler {
|
||||||
scheduler: JobScheduler,
|
scheduler: JobScheduler,
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
tasks_to_jobs: DashMap<Uuid, Uuid>,
|
tasks_to_jobs: DashMap<Uuid, Uuid>,
|
||||||
zen_api_key: Option<String>,
|
config: Arc<crate::config::Config>,
|
||||||
tavily_api_key: Option<String>,
|
pub calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
|
pub push_sender: Arc<crate::domain::notifications::push::PushSender>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scheduler {
|
impl Scheduler {
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
zen_api_key: Option<String>,
|
config: Arc<crate::config::Config>,
|
||||||
tavily_api_key: Option<String>,
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
let scheduler = JobScheduler::new().await?;
|
push_sender: Arc<crate::domain::notifications::push::PushSender>,
|
||||||
scheduler.start().await?;
|
) -> AppResult<Self> {
|
||||||
|
let scheduler = JobScheduler::new()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create scheduler: {}", e)))?;
|
||||||
|
scheduler
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to start scheduler: {}", e)))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
scheduler,
|
scheduler,
|
||||||
db,
|
db,
|
||||||
tasks_to_jobs: DashMap::new(),
|
tasks_to_jobs: DashMap::new(),
|
||||||
zen_api_key,
|
config,
|
||||||
tavily_api_key,
|
calendar_client,
|
||||||
|
tx,
|
||||||
|
push_sender,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_task_job(
|
pub async fn add_task_job(&self, task_id: Uuid, cron_expr: &str) -> AppResult<()> {
|
||||||
&self,
|
|
||||||
task_id: Uuid,
|
|
||||||
cron_expr: &str,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Remove existing job if any
|
// Remove existing job if any
|
||||||
if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) {
|
if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) {
|
||||||
let _ = self.scheduler.remove(&old_job_id).await;
|
let _ = self.scheduler.remove(&old_job_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = self.db.clone();
|
let db = self.db.clone();
|
||||||
let zen_key = self.zen_api_key.clone();
|
let config = self.config.clone();
|
||||||
let tavily_key = self.tavily_api_key.clone();
|
let tx = self.tx.clone();
|
||||||
|
let push_sender = self.push_sender.clone();
|
||||||
|
|
||||||
|
let calendar_client = self.calendar_client.clone();
|
||||||
let job = Job::new_async(cron_expr, move |_uuid, _l| {
|
let job = Job::new_async(cron_expr, move |_uuid, _l| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let zen_key = zen_key.clone();
|
let config = config.clone();
|
||||||
let tavily_key = tavily_key.clone();
|
let tx = tx.clone();
|
||||||
|
let push_sender = push_sender.clone();
|
||||||
|
let calendar_client = calendar_client.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Err(e) = Self::run_task(db, zen_key, tavily_key, task_id).await {
|
if let Err(e) =
|
||||||
eprintln!("Error in scheduled task {}: {}", task_id, e);
|
Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await
|
||||||
|
{
|
||||||
|
tracing::error!("Error in scheduled task {}: {}", task_id, e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})?;
|
})
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create job: {}", e)))?;
|
||||||
|
|
||||||
let job_id = self.scheduler.add(job).await?;
|
let job_id = self
|
||||||
|
.scheduler
|
||||||
|
.add(job)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to add job: {}", e)))?;
|
||||||
self.tasks_to_jobs.insert(task_id, job_id);
|
self.tasks_to_jobs.insert(task_id, job_id);
|
||||||
|
|
||||||
|
tracing::info!(%task_id, %cron_expr, "Added task to scheduler");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_task_job(&self, task_id: Uuid) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn remove_task_job(&self, task_id: Uuid) -> AppResult<()> {
|
||||||
if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) {
|
if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) {
|
||||||
self.scheduler.remove(&job_id).await?;
|
self.scheduler
|
||||||
|
.remove(&job_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to remove job: {}", e)))?;
|
||||||
|
tracing::info!(%task_id, "Removed task from scheduler");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_task(
|
async fn run_task(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
zen_key: Option<String>,
|
config: Arc<crate::config::Config>,
|
||||||
tavily_key: Option<String>,
|
calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
|
push_sender: Arc<crate::domain::notifications::push::PushSender>,
|
||||||
task_id: Uuid,
|
task_id: Uuid,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> AppResult<()> {
|
||||||
let task = Task::find_by_id(task_id)
|
let task = Task::find_by_id(task_id)
|
||||||
.one(&db)
|
.one(&db)
|
||||||
.await?
|
.await
|
||||||
.ok_or("Task not found")?;
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Task not found".into()))?;
|
||||||
|
|
||||||
// Create a new run entry
|
// Create a new run entry
|
||||||
let run_id = Uuid::new_v4();
|
let run_id = Uuid::new_v4();
|
||||||
|
tracing::info!(task_id = %task_id, run_id = %run_id, "Starting scheduled task execution");
|
||||||
|
|
||||||
let run = task_run::ActiveModel {
|
let run = task_run::ActiveModel {
|
||||||
id: Set(run_id),
|
id: Set(run_id),
|
||||||
task_id: Set(task_id),
|
task_id: Set(task_id),
|
||||||
|
|
@ -92,25 +123,104 @@ impl Scheduler {
|
||||||
};
|
};
|
||||||
|
|
||||||
use sea_orm::ActiveModelTrait;
|
use sea_orm::ActiveModelTrait;
|
||||||
run.insert(&db).await?;
|
run.insert(&db).await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let _ = tx.send(crate::server::notifications::WsEvent::RunStarted(
|
||||||
|
crate::domain::tasks::RecentRunResponse {
|
||||||
|
id: run_id,
|
||||||
|
task_id,
|
||||||
|
goal: task.goal.clone(),
|
||||||
|
status: "running".to_string(),
|
||||||
|
created_at: chrono::Utc::now().into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
// Start agent in background
|
// Start agent in background
|
||||||
let mut agent = Agent::new(zen_key, tavily_key, task.goal.clone())?;
|
let mut agent = Agent::new(
|
||||||
|
db.clone(),
|
||||||
|
config.zen_api_key.clone(),
|
||||||
|
config.tavily_api_key.clone(),
|
||||||
|
calendar_client.clone(),
|
||||||
|
None,
|
||||||
|
task.goal.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let (logs, answer) = match agent.run().await {
|
let (logs, answer, status) = match agent.run(&config).await {
|
||||||
Ok(res) => res,
|
Ok((logs, answer)) => {
|
||||||
Err(e) => (format!("Scheduled run failed: {}", e), None),
|
tracing::info!(task_id = %task_id, run_id = %run_id, "Scheduled task execution completed successfully");
|
||||||
|
(logs, answer, "completed".to_string())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(task_id = %task_id, run_id = %run_id, error = %e, "Scheduled task execution failed");
|
||||||
|
(
|
||||||
|
format!("Scheduled run failed: {}", e),
|
||||||
|
None,
|
||||||
|
"failed".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let run_complete = task_run::ActiveModel {
|
let run_complete = task_run::ActiveModel {
|
||||||
id: Set(run_id),
|
id: Set(run_id),
|
||||||
status: Set("completed".to_string()),
|
status: Set(status.clone()),
|
||||||
logs: Set(logs),
|
logs: Set(logs),
|
||||||
answer: Set(answer),
|
answer: Set(answer),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = run_complete.update(&db).await;
|
if let Err(e) = run_complete.update(&db).await {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to update scheduled run status for task {}: {}",
|
||||||
|
task_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await {
|
||||||
|
let _ = tx.send(crate::server::notifications::WsEvent::RunFinished(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Push notifications for scheduled runs
|
||||||
|
use crate::entities::{push_subscription, task_subscription};
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
if let Ok(subscriptions) = task_subscription::Entity::find()
|
||||||
|
.filter(task_subscription::Column::TaskId.eq(task_id))
|
||||||
|
.all(&db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for sub in subscriptions {
|
||||||
|
if let Ok(push_subs) = push_subscription::Entity::find()
|
||||||
|
.filter(push_subscription::Column::UserSub.eq(sub.user_sub))
|
||||||
|
.all(&db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for push_sub in push_subs {
|
||||||
|
let sender = push_sender.clone();
|
||||||
|
let sub_data =
|
||||||
|
crate::domain::notifications::push::PushSubscription {
|
||||||
|
endpoint: push_sub.endpoint,
|
||||||
|
p256dh: push_sub.p256dh,
|
||||||
|
auth: push_sub.auth,
|
||||||
|
};
|
||||||
|
let goal = task_response.goal.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = sender
|
||||||
|
.send_notification(
|
||||||
|
&sub_data,
|
||||||
|
&format!("Scheduled Task Completed: {}", status),
|
||||||
|
&goal,
|
||||||
|
Some(task_id),
|
||||||
|
Some(run_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
453
src/server.rs
453
src/server.rs
|
|
@ -1,453 +0,0 @@
|
||||||
use axum::{
|
|
||||||
Json, RequestPartsExt, Router,
|
|
||||||
extract::{FromRef, FromRequestParts, Path, Query, State},
|
|
||||||
http::{StatusCode, request::Parts},
|
|
||||||
routing::{get, post},
|
|
||||||
};
|
|
||||||
use axum_extra::{
|
|
||||||
TypedHeader,
|
|
||||||
headers::{Authorization, authorization::Bearer},
|
|
||||||
};
|
|
||||||
use chrono::Utc;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::agent::Agent;
|
|
||||||
use crate::entities::task::{self, Entity as Task};
|
|
||||||
use crate::entities::task_run::{self, Entity as TaskRun};
|
|
||||||
use crate::scheduler::Scheduler;
|
|
||||||
use migration::{Migrator, MigratorTrait};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pub db: DatabaseConnection,
|
|
||||||
pub scheduler: Arc<Scheduler>,
|
|
||||||
pub zen_api_key: Option<String>,
|
|
||||||
pub tavily_api_key: Option<String>,
|
|
||||||
pub verifier: Arc<crate::auth::JwksVerifier>,
|
|
||||||
pub authenticator: Arc<crate::auth::Authenticator>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CreateTaskRequest {
|
|
||||||
pub goal: String,
|
|
||||||
pub cron: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct UpdateTaskRequest {
|
|
||||||
pub goal: String,
|
|
||||||
pub cron: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TaskResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub goal: String,
|
|
||||||
pub cron: Option<String>,
|
|
||||||
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
pub runs: Vec<TaskRunResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TaskRunResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
pub logs: String,
|
|
||||||
pub answer: Option<String>,
|
|
||||||
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct RecentRunResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub task_id: Uuid,
|
|
||||||
pub goal: String,
|
|
||||||
pub status: String,
|
|
||||||
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let db = Database::connect(db_url).await?;
|
|
||||||
Migrator::up(&db, None).await?;
|
|
||||||
|
|
||||||
let zen_api_key = std::env::var("ZEN_API_KEY").ok();
|
|
||||||
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
|
|
||||||
|
|
||||||
let scheduler =
|
|
||||||
Arc::new(Scheduler::new(db.clone(), zen_api_key.clone(), tavily_api_key.clone()).await?);
|
|
||||||
|
|
||||||
// Load existing scheduled tasks
|
|
||||||
let existing_tasks = Task::find().all(&db).await?;
|
|
||||||
for task in existing_tasks {
|
|
||||||
if let Some(cron) = task.cron {
|
|
||||||
let _ = scheduler.add_task_job(task.id, &cron).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let authentik_issuer =
|
|
||||||
std::env::var("AUTHENTIK_ISSUER").map_err(|_| "AUTHENTIK_ISSUER not set")?;
|
|
||||||
let authentik_client_id =
|
|
||||||
std::env::var("AUTHENTIK_CLIENT_ID").map_err(|_| "AUTHENTIK_CLIENT_ID not set")?;
|
|
||||||
let authentik_client_secret =
|
|
||||||
std::env::var("AUTHENTIK_CLIENT_SECRET").map_err(|_| "AUTHENTIK_CLIENT_SECRET not set")?;
|
|
||||||
|
|
||||||
let verifier = Arc::new(crate::auth::JwksVerifier::new(authentik_issuer.clone()).await?);
|
|
||||||
let authenticator = Arc::new(
|
|
||||||
crate::auth::Authenticator::new(
|
|
||||||
authentik_issuer,
|
|
||||||
authentik_client_id,
|
|
||||||
authentik_client_secret,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
|
||||||
db,
|
|
||||||
scheduler,
|
|
||||||
zen_api_key,
|
|
||||||
tavily_api_key,
|
|
||||||
verifier,
|
|
||||||
authenticator,
|
|
||||||
});
|
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/api/tasks", post(create_task).get(list_tasks))
|
|
||||||
.route("/api/tasks/:id", get(get_task).put(update_task))
|
|
||||||
.route("/api/tasks/:id/runs", post(rerun_task))
|
|
||||||
.route("/api/runs/recent", get(get_recent_runs))
|
|
||||||
.route("/api/auth/callback", get(auth_callback))
|
|
||||||
.route("/api/auth/refresh", post(auth_refresh))
|
|
||||||
.layer(cors)
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
|
||||||
let addr = format!("0.0.0.0:{}", port);
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
||||||
println!("Server running on http://localhost:{}", port);
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_tasks(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> {
|
|
||||||
let tasks = Task::find()
|
|
||||||
.find_with_related(TaskRun)
|
|
||||||
.order_by_desc(task::Column::CreatedAt)
|
|
||||||
.all(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let response = tasks
|
|
||||||
.into_iter()
|
|
||||||
.map(|(t, mut runs)| {
|
|
||||||
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
||||||
TaskResponse {
|
|
||||||
id: t.id,
|
|
||||||
goal: t.goal,
|
|
||||||
cron: t.cron,
|
|
||||||
created_at: t.created_at,
|
|
||||||
runs: runs
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| TaskRunResponse {
|
|
||||||
id: r.id,
|
|
||||||
status: r.status,
|
|
||||||
logs: r.logs,
|
|
||||||
answer: r.answer,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_task(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(payload): Json<CreateTaskRequest>,
|
|
||||||
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
|
||||||
let task_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
// Initial task save
|
|
||||||
let new_task = task::ActiveModel {
|
|
||||||
id: Set(task_id),
|
|
||||||
goal: Set(payload.goal.clone()),
|
|
||||||
cron: Set(payload.cron.clone()),
|
|
||||||
created_at: Set(Utc::now().into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
new_task
|
|
||||||
.insert(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(cron) = &payload.cron {
|
|
||||||
let _ = state.scheduler.add_task_job(task_id, cron).await;
|
|
||||||
} else {
|
|
||||||
let _ = state.scheduler.remove_task_job(task_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
get_task_inner(task_id, &state).await.map(Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rerun_task(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
|
||||||
let task = Task::find_by_id(id)
|
|
||||||
.one(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
|
|
||||||
|
|
||||||
execute_agent_run(state, task.id, task.goal).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_agent_run(
|
|
||||||
state: Arc<AppState>,
|
|
||||||
task_id: Uuid,
|
|
||||||
goal: String,
|
|
||||||
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
|
||||||
let run_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
// Initial run save
|
|
||||||
let new_run = task_run::ActiveModel {
|
|
||||||
id: Set(run_id),
|
|
||||||
task_id: Set(task_id),
|
|
||||||
status: Set("running".to_string()),
|
|
||||||
logs: Set(String::new()),
|
|
||||||
answer: Set(None),
|
|
||||||
created_at: Set(Utc::now().into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
new_run
|
|
||||||
.insert(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let mut agent = Agent::new(
|
|
||||||
state.zen_api_key.clone(),
|
|
||||||
state.tavily_api_key.clone(),
|
|
||||||
goal.clone(),
|
|
||||||
)
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let (logs, answer) = match agent.run().await {
|
|
||||||
Ok((logs, answer)) => (logs, answer),
|
|
||||||
Err(e) => (format!("Execution failed: {}", e), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update with final logs and status
|
|
||||||
let mut run: task_run::ActiveModel = TaskRun::find_by_id(run_id)
|
|
||||||
.one(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"Run not found after insert".to_string(),
|
|
||||||
))?
|
|
||||||
.into();
|
|
||||||
|
|
||||||
run.logs = Set(logs.clone());
|
|
||||||
run.answer = Set(answer.clone());
|
|
||||||
run.status = Set("completed".to_string());
|
|
||||||
|
|
||||||
run.update(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
get_task_inner(task_id, &state).await.map(Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_task(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<UpdateTaskRequest>,
|
|
||||||
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
|
||||||
let mut task: task::ActiveModel = Task::find_by_id(id)
|
|
||||||
.one(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?
|
|
||||||
.into();
|
|
||||||
|
|
||||||
task.goal = Set(payload.goal.clone());
|
|
||||||
task.cron = Set(payload.cron.clone());
|
|
||||||
|
|
||||||
task.update(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(cron) = &payload.cron {
|
|
||||||
let _ = state.scheduler.add_task_job(id, cron).await;
|
|
||||||
} else {
|
|
||||||
let _ = state.scheduler.remove_task_job(id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
get_task_inner(id, &state).await.map(Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct AuthenticatedUser(pub crate::auth::Claims);
|
|
||||||
|
|
||||||
#[axum::async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
|
||||||
where
|
|
||||||
Arc<AppState>: axum::extract::FromRef<S>,
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = (StatusCode, String);
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let app_state = Arc::<AppState>::from_ref(state);
|
|
||||||
|
|
||||||
let TypedHeader(Authorization(bearer)) = parts
|
|
||||||
.extract::<TypedHeader<Authorization<Bearer>>>()
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"Missing or invalid Authorization header".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let claims = app_state
|
|
||||||
.verifier
|
|
||||||
.verify(bearer.token())
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
format!("Token verification failed: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(AuthenticatedUser(claims))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct AuthCallbackQuery {
|
|
||||||
pub code: String,
|
|
||||||
pub redirect_uri: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RefreshRequest {
|
|
||||||
refresh_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn auth_refresh(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(payload): Json<RefreshRequest>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
state
|
|
||||||
.authenticator
|
|
||||||
.refresh_token(payload.refresh_token)
|
|
||||||
.await
|
|
||||||
.map(Json)
|
|
||||||
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn auth_callback(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Query(query): Query<AuthCallbackQuery>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
state
|
|
||||||
.authenticator
|
|
||||||
.exchange_code(query.code, query.redirect_uri)
|
|
||||||
.await
|
|
||||||
.map(Json)
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Token exchange failed: {}", e),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_task(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
|
||||||
get_task_inner(id, &state).await.map(Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_task_inner(id: Uuid, state: &AppState) -> Result<TaskResponse, (StatusCode, String)> {
|
|
||||||
let results = Task::find_by_id(id)
|
|
||||||
.find_with_related(TaskRun)
|
|
||||||
.all(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let (t, mut runs) = results
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
|
|
||||||
|
|
||||||
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
||||||
|
|
||||||
Ok(TaskResponse {
|
|
||||||
id: t.id,
|
|
||||||
goal: t.goal,
|
|
||||||
cron: t.cron,
|
|
||||||
created_at: t.created_at,
|
|
||||||
runs: runs
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| TaskRunResponse {
|
|
||||||
id: r.id,
|
|
||||||
status: r.status,
|
|
||||||
logs: r.logs,
|
|
||||||
answer: r.answer,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_recent_runs(
|
|
||||||
_user: AuthenticatedUser,
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
) -> Result<Json<Vec<RecentRunResponse>>, (StatusCode, String)> {
|
|
||||||
let results = TaskRun::find()
|
|
||||||
.find_also_related(Task)
|
|
||||||
.order_by_desc(task_run::Column::CreatedAt)
|
|
||||||
.limit(50)
|
|
||||||
.all(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let response = results
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(run, task_opt)| {
|
|
||||||
task_opt.map(|task| RecentRunResponse {
|
|
||||||
id: run.id,
|
|
||||||
task_id: run.task_id,
|
|
||||||
goal: task.goal,
|
|
||||||
status: run.status,
|
|
||||||
created_at: run.created_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
169
src/server/auth.rs
Normal file
169
src/server/auth.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
use axum::{
|
||||||
|
Json, RequestPartsExt,
|
||||||
|
extract::{FromRef, FromRequestParts, Query, State},
|
||||||
|
http::request::Parts,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use axum_extra::{
|
||||||
|
TypedHeader,
|
||||||
|
extract::cookie::{Cookie, CookieJar, SameSite},
|
||||||
|
headers::{Authorization, authorization::Bearer},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub struct AuthenticatedUser(pub crate::domain::auth::Claims);
|
||||||
|
|
||||||
|
#[axum::async_trait]
|
||||||
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||||
|
where
|
||||||
|
Arc<AppState>: axum::extract::FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app_state = Arc::<AppState>::from_ref(state);
|
||||||
|
|
||||||
|
let token = if let Ok(TypedHeader(Authorization(bearer))) =
|
||||||
|
parts.extract::<TypedHeader<Authorization<Bearer>>>().await
|
||||||
|
{
|
||||||
|
Some(bearer.token().to_string())
|
||||||
|
} else {
|
||||||
|
let jar = parts
|
||||||
|
.extract::<CookieJar>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
jar.get("access_token")
|
||||||
|
.map(|cookie| cookie.value().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = token
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid access token".into()))?;
|
||||||
|
|
||||||
|
let claims = app_state
|
||||||
|
.verifier
|
||||||
|
.verify(&token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Unauthorized(format!("Token verification failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(AuthenticatedUser(claims))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct AuthCallbackQuery {
|
||||||
|
pub code: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_refresh(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(payload): Json<RefreshRequest>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let refresh_token = payload
|
||||||
|
.refresh_token
|
||||||
|
.filter(|token| !token.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
jar.get("refresh_token")
|
||||||
|
.map(|cookie| cookie.value().to_string())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Missing refresh token".into()))?;
|
||||||
|
|
||||||
|
let data = state
|
||||||
|
.authenticator
|
||||||
|
.refresh_token(refresh_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Unauthorized(e.to_string()))?;
|
||||||
|
|
||||||
|
let jar = update_auth_cookies(jar, &data, &state.config);
|
||||||
|
Ok((jar, Json(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_callback(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<AuthCallbackQuery>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
let data = state
|
||||||
|
.authenticator
|
||||||
|
.exchange_code(query.code, query.redirect_uri)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?;
|
||||||
|
|
||||||
|
let jar = update_auth_cookies(jar, &data, &state.config);
|
||||||
|
Ok((jar, Json(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> impl IntoResponse {
|
||||||
|
let jar = clear_auth_cookies(jar, &state.config);
|
||||||
|
(jar, axum::http::StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_session(user: AuthenticatedUser) -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"authenticated": true,
|
||||||
|
"user": user.0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure(config: &crate::config::Config) -> bool {
|
||||||
|
config.cookie_secure
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_auth_cookies(
|
||||||
|
jar: CookieJar,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
config: &crate::config::Config,
|
||||||
|
) -> CookieJar {
|
||||||
|
let access_token = data.get("access_token");
|
||||||
|
let refresh_token = data.get("refresh_token");
|
||||||
|
|
||||||
|
let mut jar = jar;
|
||||||
|
|
||||||
|
if let Some(token) = access_token.and_then(|t| t.as_str()) {
|
||||||
|
let cookie = Cookie::build(("access_token", token.to_owned()))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.secure(secure(config))
|
||||||
|
.build();
|
||||||
|
jar = jar.add(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(token) = refresh_token.and_then(|t| t.as_str()) {
|
||||||
|
let cookie = Cookie::build(("refresh_token", token.to_owned()))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.secure(secure(config))
|
||||||
|
.build();
|
||||||
|
jar = jar.add(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
jar
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_auth_cookies(jar: CookieJar, config: &crate::config::Config) -> CookieJar {
|
||||||
|
let mut jar = jar;
|
||||||
|
for name in ["access_token", "refresh_token"] {
|
||||||
|
let cookie = Cookie::build((name, ""))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.secure(secure(config))
|
||||||
|
.max_age(cookie::time::Duration::seconds(0))
|
||||||
|
.build();
|
||||||
|
jar = jar.add(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
jar
|
||||||
|
}
|
||||||
68
src/server/chat.rs
Normal file
68
src/server/chat.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
use crate::domain::agent::api::Message;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::server::AppState;
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatPayload {
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatResult {
|
||||||
|
pub message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: crate::server::auth::AuthenticatedUser,
|
||||||
|
Json(payload): Json<ChatPayload>,
|
||||||
|
) -> Result<Json<ChatResult>, AppError> {
|
||||||
|
let msg_count = payload.messages.len();
|
||||||
|
tracing::info!("Received chat request with {} messages", msg_count);
|
||||||
|
|
||||||
|
let mut messages = payload.messages;
|
||||||
|
|
||||||
|
// Inject current date awareness if not already present or as a fresh system message
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let date_str = now.format("%A, %B %e, %Y at %l:%M %P").to_string();
|
||||||
|
messages.insert(
|
||||||
|
0,
|
||||||
|
Message {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(format!(
|
||||||
|
"The current date and time is {}. Today is {}. You are in interactive chat mode.",
|
||||||
|
date_str,
|
||||||
|
now.format("%Y-%m-%d")
|
||||||
|
)),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut agent = crate::domain::agent::Agent::with_messages(
|
||||||
|
state.db.clone(),
|
||||||
|
state.config.zen_api_key.clone(),
|
||||||
|
state.config.tavily_api_key.clone(),
|
||||||
|
state.calendar_client.clone(),
|
||||||
|
Some(user.0.sub),
|
||||||
|
messages,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tracing::info!("Starting interactive agent turn");
|
||||||
|
let assistant_message =
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(180), agent.execute_turn()).await
|
||||||
|
{
|
||||||
|
Ok(res) => res?,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!("Interactive chat agent turn timed out after 180s");
|
||||||
|
return Err(AppError::Internal("Agent turn timed out".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ChatResult {
|
||||||
|
message: assistant_message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
219
src/server/mod.rs
Normal file
219
src/server/mod.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
pub mod auth;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod tasks;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
http::HeaderValue,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use migration::{Migrator, MigratorTrait};
|
||||||
|
use sea_orm::{Database, DatabaseConnection, EntityTrait};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
|
use crate::scheduler::Scheduler;
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub scheduler: Arc<Scheduler>,
|
||||||
|
pub config: Arc<crate::config::Config>,
|
||||||
|
pub verifier: Arc<crate::domain::auth::JwksVerifier>,
|
||||||
|
pub authenticator: Arc<crate::domain::auth::Authenticator>,
|
||||||
|
pub calendar_client: Arc<crate::domain::calendar::CalendarClient>,
|
||||||
|
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(config: crate::config::Config) -> AppResult<()> {
|
||||||
|
let db = setup_database(&config.database_url).await?;
|
||||||
|
|
||||||
|
let config = Arc::new(config);
|
||||||
|
|
||||||
|
let (tx, _) = tokio::sync::broadcast::channel(100);
|
||||||
|
|
||||||
|
let push_sender = Arc::new(crate::domain::notifications::push::PushSender::new(
|
||||||
|
&config.vapid_private_key.clone(),
|
||||||
|
)?);
|
||||||
|
|
||||||
|
let (verifier, authenticator) = setup_auth(&config).await?;
|
||||||
|
let calendar_client = Arc::new(crate::domain::calendar::CalendarClient::new(
|
||||||
|
config.calendar_api_url.clone(),
|
||||||
|
authenticator.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let scheduler = Arc::new(
|
||||||
|
Scheduler::new(
|
||||||
|
db.clone(),
|
||||||
|
config.clone(),
|
||||||
|
calendar_client.clone(),
|
||||||
|
tx.clone(),
|
||||||
|
push_sender.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load existing scheduled tasks
|
||||||
|
use crate::entities::task::Entity as Task;
|
||||||
|
let existing_tasks = Task::find()
|
||||||
|
.all(&db)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
for task in existing_tasks {
|
||||||
|
if let Some(cron) = task.cron {
|
||||||
|
let _ = scheduler.add_task_job(task.id, &cron).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
db,
|
||||||
|
scheduler,
|
||||||
|
config: config.clone(),
|
||||||
|
verifier,
|
||||||
|
authenticator,
|
||||||
|
calendar_client,
|
||||||
|
tx,
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = build_app(state, &config);
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{}", config.port);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
tracing::info!("Server running on http://localhost:{}", config.port);
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_database(database_url: &str) -> AppResult<DatabaseConnection> {
|
||||||
|
let db = Database::connect(database_url)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
Migrator::up(&db, None)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_auth(
|
||||||
|
config: &crate::config::Config,
|
||||||
|
) -> AppResult<(
|
||||||
|
Arc<crate::domain::auth::JwksVerifier>,
|
||||||
|
Arc<crate::domain::auth::Authenticator>,
|
||||||
|
)> {
|
||||||
|
let verifier = Arc::new(
|
||||||
|
crate::domain::auth::JwksVerifier::new(
|
||||||
|
config.authentik_issuer.clone(),
|
||||||
|
config.authentik_client_id.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
|
||||||
|
);
|
||||||
|
let authenticator = Arc::new(
|
||||||
|
crate::domain::auth::Authenticator::new(
|
||||||
|
config.authentik_issuer.clone(),
|
||||||
|
config.authentik_client_id.clone(),
|
||||||
|
config.authentik_client_secret.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((verifier, authenticator))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_app(state: Arc<AppState>, config: &crate::config::Config) -> Router {
|
||||||
|
let cors = build_cors_layer(config);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/api/tasks", post(tasks::create_task).get(tasks::list_tasks))
|
||||||
|
.route("/api/tasks/:id", get(tasks::get_task).put(tasks::update_task))
|
||||||
|
.route("/api/tasks/:id/runs", post(tasks::rerun_task))
|
||||||
|
.route("/api/runs/recent", get(tasks::get_recent_runs))
|
||||||
|
.route("/api/auth/session", get(auth::auth_session))
|
||||||
|
.route("/api/auth/callback", get(auth::auth_callback))
|
||||||
|
.route("/api/auth/refresh", post(auth::auth_refresh))
|
||||||
|
.route("/api/auth/logout", post(auth::auth_logout))
|
||||||
|
.route("/api/chat", post(chat::chat_handler))
|
||||||
|
.route("/api/ws", get(notifications::ws_handler))
|
||||||
|
.route("/api/notifications/register", post(notifications::push_handlers::register_push))
|
||||||
|
.route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key))
|
||||||
|
.route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status))
|
||||||
|
.route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task))
|
||||||
|
.layer(axum::middleware::from_fn(log_error_responses))
|
||||||
|
.layer(cors)
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::limit::RequestBodyLimitLayer::new(1024 * 1024)) // 1MB limit
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_error_responses(
|
||||||
|
req: axum::extract::Request,
|
||||||
|
next: axum::middleware::Next,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let method = req.method().clone();
|
||||||
|
let uri = req.uri().clone();
|
||||||
|
let res = next.run(req).await;
|
||||||
|
let status = res.status();
|
||||||
|
|
||||||
|
if status.is_client_error() || status.is_server_error() {
|
||||||
|
tracing::error!(%method, %uri, %status, "Response error");
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_cors_layer(config: &crate::config::Config) -> CorsLayer {
|
||||||
|
let allow_origin = if let Some(origins) = &config.cors_allowed_origins {
|
||||||
|
let values: Vec<HeaderValue> = origins
|
||||||
|
.split(',')
|
||||||
|
.map(|origin| origin.trim())
|
||||||
|
.filter(|origin| !origin.is_empty())
|
||||||
|
.filter_map(|origin| HeaderValue::from_str(origin).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if values.is_empty() {
|
||||||
|
AllowOrigin::mirror_request()
|
||||||
|
} else {
|
||||||
|
AllowOrigin::list(values)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AllowOrigin::mirror_request()
|
||||||
|
};
|
||||||
|
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(allow_origin)
|
||||||
|
.allow_methods([
|
||||||
|
axum::http::Method::GET,
|
||||||
|
axum::http::Method::POST,
|
||||||
|
axum::http::Method::PUT,
|
||||||
|
axum::http::Method::PATCH,
|
||||||
|
axum::http::Method::DELETE,
|
||||||
|
axum::http::Method::OPTIONS,
|
||||||
|
])
|
||||||
|
.allow_headers([
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::header::ACCEPT,
|
||||||
|
])
|
||||||
|
.allow_credentials(true)
|
||||||
|
}
|
||||||
48
src/server/notifications.rs
Normal file
48
src/server/notifications.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/server/notifications/push_handlers.rs
Normal file
133
src/server/notifications/push_handlers.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entities::{push_subscription, task_subscription};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::server::auth::AuthenticatedUser;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct RegisterPushRequest {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub p256dh: String,
|
||||||
|
pub auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_push(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<RegisterPushRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let user_sub = user.0.sub;
|
||||||
|
tracing::info!(
|
||||||
|
"Registering push subscription for user: {} with endpoint: {}",
|
||||||
|
user_sub,
|
||||||
|
payload.endpoint
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if subscription exists
|
||||||
|
let existing = push_subscription::Entity::find()
|
||||||
|
.filter(push_subscription::Column::UserSub.eq(user_sub.clone()))
|
||||||
|
.filter(push_subscription::Column::Endpoint.eq(payload.endpoint.clone()))
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let new_sub = push_subscription::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
user_sub: Set(user_sub),
|
||||||
|
endpoint: Set(payload.endpoint),
|
||||||
|
p256dh: Set(payload.p256dh),
|
||||||
|
auth: Set(payload.auth),
|
||||||
|
created_at: Set(Utc::now().into()),
|
||||||
|
};
|
||||||
|
new_sub
|
||||||
|
.insert(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "status": "registered" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn subscribe_task(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Path(task_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let user_sub = user.0.sub;
|
||||||
|
|
||||||
|
let existing = task_subscription::Entity::find()
|
||||||
|
.filter(task_subscription::Column::UserSub.eq(user_sub.clone()))
|
||||||
|
.filter(task_subscription::Column::TaskId.eq(task_id))
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let new_sub = task_subscription::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
user_sub: Set(user_sub),
|
||||||
|
task_id: Set(task_id),
|
||||||
|
created_at: Set(Utc::now().into()),
|
||||||
|
};
|
||||||
|
new_sub
|
||||||
|
.insert(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "status": "subscribed" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unsubscribe_task(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Path(task_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let user_sub = user.0.sub;
|
||||||
|
|
||||||
|
task_subscription::Entity::delete_many()
|
||||||
|
.filter(task_subscription::Column::UserSub.eq(user_sub))
|
||||||
|
.filter(task_subscription::Column::TaskId.eq(task_id))
|
||||||
|
.exec(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "status": "unsubscribed" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_vapid_key(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let public_key = state.scheduler.push_sender.get_public_key()?;
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key);
|
||||||
|
Ok(Json(serde_json::json!({ "publicKey": encoded })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_subscription_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Path(task_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let user_sub = user.0.sub;
|
||||||
|
|
||||||
|
let existing = task_subscription::Entity::find()
|
||||||
|
.filter(task_subscription::Column::UserSub.eq(user_sub))
|
||||||
|
.filter(task_subscription::Column::TaskId.eq(task_id))
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({ "isSubscribed": existing.is_some() }),
|
||||||
|
))
|
||||||
|
}
|
||||||
205
src/server/tasks.rs
Normal file
205
src/server/tasks.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use sea_orm::{EntityTrait, QueryOrder, QuerySelect};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
use super::auth::AuthenticatedUser;
|
||||||
|
use crate::domain::tasks::{
|
||||||
|
self, CreateTaskRequest, RecentRunResponse, TaskResponse, UpdateTaskRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
|
||||||
|
pub async fn list_tasks(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> AppResult<Json<Vec<TaskResponse>>> {
|
||||||
|
let tasks = crate::entities::task::Entity::find()
|
||||||
|
.find_with_related(crate::entities::task_run::Entity)
|
||||||
|
.order_by_desc(crate::entities::task::Column::CreatedAt)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = tasks
|
||||||
|
.into_iter()
|
||||||
|
.map(|(t, mut runs)| {
|
||||||
|
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
TaskResponse {
|
||||||
|
id: t.id,
|
||||||
|
goal: t.goal,
|
||||||
|
cron: t.cron,
|
||||||
|
created_at: t.created_at,
|
||||||
|
runs: runs
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| tasks::TaskRunResponse {
|
||||||
|
id: r.id,
|
||||||
|
status: r.status,
|
||||||
|
logs: r.logs,
|
||||||
|
answer: r.answer,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_task(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(payload): Json<CreateTaskRequest>,
|
||||||
|
) -> AppResult<Json<TaskResponse>> {
|
||||||
|
if payload.goal.trim().is_empty() {
|
||||||
|
return Err(crate::error::AppError::InvalidRequest(
|
||||||
|
"Goal cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.goal.trim().len() < 5 {
|
||||||
|
return Err(crate::error::AppError::InvalidRequest(
|
||||||
|
"Goal is too short (min 5 characters)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_id = Uuid::new_v4();
|
||||||
|
tracing::info!(%task_id, goal = %payload.goal, "Creating new task");
|
||||||
|
|
||||||
|
let new_task = crate::entities::task::ActiveModel {
|
||||||
|
id: sea_orm::Set(task_id),
|
||||||
|
goal: sea_orm::Set(payload.goal),
|
||||||
|
cron: sea_orm::Set(payload.cron.clone()),
|
||||||
|
created_at: sea_orm::Set(chrono::Utc::now().into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
use sea_orm::ActiveModelTrait;
|
||||||
|
new_task.insert(&state.db).await?;
|
||||||
|
|
||||||
|
if let Some(cron) = &payload.cron {
|
||||||
|
let _ = state.scheduler.add_task_job(task_id, cron).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_response = tasks::get_task_inner(task_id, &state.db).await?;
|
||||||
|
let _ = state
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::TaskCreated(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
Ok(Json(task_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rerun_task(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<TaskResponse>> {
|
||||||
|
let task = crate::entities::task::Entity::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(task_id = %task.id, "Manually triggering task rerun");
|
||||||
|
|
||||||
|
tasks::execute_agent_run(
|
||||||
|
&state.db,
|
||||||
|
&state.scheduler,
|
||||||
|
&state.config,
|
||||||
|
state.calendar_client.clone(),
|
||||||
|
task.id,
|
||||||
|
task.goal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_task(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateTaskRequest>,
|
||||||
|
) -> AppResult<Json<TaskResponse>> {
|
||||||
|
if payload.goal.trim().is_empty() {
|
||||||
|
return Err(crate::error::AppError::InvalidRequest(
|
||||||
|
"Goal cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.goal.trim().len() < 5 {
|
||||||
|
return Err(crate::error::AppError::InvalidRequest(
|
||||||
|
"Goal is too short (min 5 characters)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let task: crate::entities::task::ActiveModel = crate::entities::task::Entity::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let mut task = task;
|
||||||
|
tracing::info!(task_id = %id, goal = %payload.goal, "Updating task");
|
||||||
|
|
||||||
|
task.goal = sea_orm::Set(payload.goal);
|
||||||
|
task.cron = sea_orm::Set(payload.cron.clone());
|
||||||
|
|
||||||
|
use sea_orm::ActiveModelTrait;
|
||||||
|
task.update(&state.db).await?;
|
||||||
|
|
||||||
|
if let Some(cron) = &payload.cron {
|
||||||
|
let _ = state.scheduler.add_task_job(id, cron).await;
|
||||||
|
} else {
|
||||||
|
let _ = state.scheduler.remove_task_job(id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_response = tasks::get_task_inner(id, &state.db).await?;
|
||||||
|
let _ = state
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::TaskUpdated(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
Ok(Json(task_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_task(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> AppResult<Json<TaskResponse>> {
|
||||||
|
tasks::get_task_inner(id, &state.db)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_recent_runs(
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> AppResult<Json<Vec<RecentRunResponse>>> {
|
||||||
|
let results = crate::entities::task_run::Entity::find()
|
||||||
|
.find_also_related(crate::entities::task::Entity)
|
||||||
|
.order_by_desc(crate::entities::task_run::Column::CreatedAt)
|
||||||
|
.limit(50)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(run, task_opt)| {
|
||||||
|
task_opt.map(|task| RecentRunResponse {
|
||||||
|
id: run.id,
|
||||||
|
task_id: run.task_id,
|
||||||
|
goal: task.goal,
|
||||||
|
status: run.status,
|
||||||
|
created_at: run.created_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
39
src/tests.rs
Normal file
39
src/tests.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_error_into_response() {
|
||||||
|
let err = AppError::NotFound("Resource not found".into());
|
||||||
|
let response = err.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let err = AppError::Unauthorized("Invalid token".into());
|
||||||
|
let response = err.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let err = AppError::Internal("Server glitch".into());
|
||||||
|
let response = err.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_config_validation() {
|
||||||
|
// We can't easily clear all env vars in multi-threaded tests,
|
||||||
|
// but we can test that it fails if a required one is missing (if we can ensure it's missing)
|
||||||
|
// However, for this environment, it's safer to test the mapping logic if it was more complex.
|
||||||
|
|
||||||
|
// Instead, let's test a helper if we had one, or just verify AppResult works as expected.
|
||||||
|
let result: AppResult<Config> = Err(AppError::Config("Missing DATABASE_URL".into()));
|
||||||
|
assert!(result.is_err());
|
||||||
|
if let Err(AppError::Config(msg)) = result {
|
||||||
|
assert_eq!(msg, "Missing DATABASE_URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_variants() {
|
||||||
|
let err = AppError::InvalidRequest("Bad input".into());
|
||||||
|
assert!(err.to_string().contains("Bad input"));
|
||||||
|
}
|
||||||
90
src/tools.rs
90
src/tools.rs
|
|
@ -1,90 +0,0 @@
|
||||||
use crate::api::{self, FunctionDefinition, Message, Tool, ToolCall};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
pub fn get_tools() -> Vec<Tool> {
|
|
||||||
vec![
|
|
||||||
Tool {
|
|
||||||
tool_type: "function".to_string(),
|
|
||||||
function: FunctionDefinition {
|
|
||||||
name: "google_search".to_string(),
|
|
||||||
description: "Search the web for information".to_string(),
|
|
||||||
parameters: serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The search query"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["query"]
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Tool {
|
|
||||||
tool_type: "function".to_string(),
|
|
||||||
function: FunctionDefinition {
|
|
||||||
name: "finish".to_string(),
|
|
||||||
description: "Finish the task".to_string(),
|
|
||||||
parameters: serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"result": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The result of the task"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["result"]
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_tool_call(
|
|
||||||
tool_call: &ToolCall,
|
|
||||||
tavily_api_key: &Option<String>,
|
|
||||||
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
|
||||||
let mut answer = None;
|
|
||||||
let name = &tool_call.function.name;
|
|
||||||
|
|
||||||
let (content, written) = if name == "google_search" {
|
|
||||||
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
|
|
||||||
let query = args.get("query").ok_or("Missing query argument")?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"--- Executing tool: google_search(query: \"{}\") ---",
|
|
||||||
query
|
|
||||||
);
|
|
||||||
|
|
||||||
let search_result = if let Some(key) = tavily_api_key {
|
|
||||||
match api::perform_search(query, key).await {
|
|
||||||
Ok(results) => results,
|
|
||||||
Err(e) => format!("Search error: {}", e),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
|
|
||||||
};
|
|
||||||
(search_result, false)
|
|
||||||
} else if name == "finish" {
|
|
||||||
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
|
|
||||||
let result = args.get("result").ok_or("Missing result argument")?;
|
|
||||||
|
|
||||||
println!("--- Finishing task: {}", result);
|
|
||||||
|
|
||||||
answer = Some(result.clone());
|
|
||||||
(result.clone(), true)
|
|
||||||
} else {
|
|
||||||
(format!("Error: Unknown tool {}", name), false)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
Message {
|
|
||||||
role: "tool".to_string(),
|
|
||||||
content: Some(content),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: Some(tool_call.id.clone()),
|
|
||||||
},
|
|
||||||
written,
|
|
||||||
answer,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue