diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml
index fdcd8f5..735c2ef 100644
--- a/.forgejo/workflows/pipeline.yaml
+++ b/.forgejo/workflows/pipeline.yaml
@@ -11,6 +11,31 @@ jobs:
with:
node-version: 24
- 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: |
cd frontend
npm install
diff --git a/.gitignore b/.gitignore
index 771536c..9bf488d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
/target
node_modules
-/frontend/dist
\ No newline at end of file
+/frontend/dist
+.env
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..907bd9b
--- /dev/null
+++ b/AGENTS.md
@@ -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.
diff --git a/Cargo.lock b/Cargo.lock
index 1a9f8e6..b8d0c9c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -93,12 +93,29 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "anyhow"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -161,9 +178,10 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
+ "base64 0.22.1",
"bytes",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -179,8 +197,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -196,7 +216,7 @@ dependencies = [
"async-trait",
"bytes",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"mime",
@@ -217,10 +237,11 @@ dependencies = [
"axum",
"axum-core",
"bytes",
- "fastrand",
+ "cookie",
+ "fastrand 2.3.0",
"futures-util",
"headers",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"mime",
@@ -238,6 +259,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -264,6 +297,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "binstring"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -323,9 +368,12 @@ version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
- "base64",
+ "base64 0.22.1",
"chrono",
+ "cookie",
"dashmap",
+ "dotenvy",
+ "isahc",
"jsonwebtoken",
"migration",
"reqwest",
@@ -333,10 +381,14 @@ dependencies = [
"sea-orm-migration",
"serde",
"serde_json",
+ "thiserror 2.0.18",
"tokio",
"tokio-cron-scheduler",
"tower-http 0.5.2",
+ "tracing",
+ "tracing-subscriber",
"uuid",
+ "web-push",
]
[[package]]
@@ -379,6 +431,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "castaway"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
+
[[package]]
name = "cc"
version = "1.2.55"
@@ -465,6 +523,17 @@ version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+[[package]]
+name = "coarsetime"
+version = "0.1.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2"
+dependencies = [
+ "libc",
+ "wasix",
+ "wasm-bindgen",
+]
+
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -480,12 +549,29 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-oid"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b"
+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -574,6 +660,43 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "ct-codecs"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
+
+[[package]]
+name = "curl"
+version = "0.4.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.85+curl-8.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0efa6142b5ecc05f6d3eaa39e6af4888b9d3939273fb592c92b7088a8cf3fdb"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -650,17 +773,56 @@ dependencies = [
"parking_lot_core",
]
+[[package]]
+name = "data-encoding"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+
+[[package]]
+name = "der"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
+dependencies = [
+ "const-oid 0.6.2",
+ "der_derive",
+]
+
+[[package]]
+name = "der"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468 0.6.0",
+ "zeroize",
+]
+
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
- "const-oid",
- "pem-rfc7468",
+ "const-oid 0.9.6",
+ "pem-rfc7468 0.7.0",
"zeroize",
]
+[[package]]
+name = "der_derive"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "synstructure 0.12.6",
+]
+
[[package]]
name = "deranged"
version = "0.5.5"
@@ -731,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
- "const-oid",
+ "const-oid 0.9.6",
"crypto-common",
"subtle",
]
@@ -759,12 +921,30 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
- "der",
+ "der 0.7.10",
"digest",
"elliptic-curve",
"rfc6979",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "ece"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
+dependencies = [
+ "base64 0.21.7",
+ "byteorder",
+ "hex",
+ "hkdf",
+ "lazy_static",
+ "once_cell",
+ "openssl",
+ "serde",
+ "sha2",
+ "thiserror 1.0.69",
]
[[package]]
@@ -773,8 +953,18 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
- "pkcs8",
- "signature",
+ "pkcs8 0.10.2",
+ "signature 2.2.0",
+]
+
+[[package]]
+name = "ed25519-compact"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd"
+dependencies = [
+ "ct-codecs",
+ "getrandom 0.3.4",
]
[[package]]
@@ -813,8 +1003,8 @@ dependencies = [
"generic-array",
"group",
"hkdf",
- "pem-rfc7468",
- "pkcs8",
+ "pem-rfc7468 0.7.0",
+ "pkcs8 0.10.2",
"rand_core",
"sec1",
"subtle",
@@ -857,6 +1047,12 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -868,6 +1064,15 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fastrand"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+dependencies = [
+ "instant",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1007,6 +1212,34 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+[[package]]
+name = "futures-lite"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
+dependencies = [
+ "fastrand 1.9.0",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
+ "pin-project-lite",
+ "waker-fn",
+]
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand 2.3.0",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -1064,9 +1297,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -1097,7 +1332,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.0",
"indexmap",
"slab",
"tokio",
@@ -1152,10 +1387,10 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"headers-core",
- "http",
+ "http 1.4.0",
"httpdate",
"mime",
"sha1",
@@ -1167,7 +1402,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1206,6 +1441,30 @@ dependencies = [
"digest",
]
+[[package]]
+name = "hmac-sha1-compact"
+version = "1.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823"
+
+[[package]]
+name = "hmac-sha256"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hmac-sha512"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5075d41b75a022af043a5bbf49b89abf17665d5aebf6f6ec64ffff207d87654d"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "home"
version = "0.5.12"
@@ -1215,6 +1474,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -1232,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1243,7 +1513,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.4.0",
"http-body",
"pin-project-lite",
]
@@ -1271,7 +1541,7 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
- "http",
+ "http 1.4.0",
"http-body",
"httparse",
"httpdate",
@@ -1289,7 +1559,7 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
+ "http 1.4.0",
"hyper",
"hyper-util",
"rustls",
@@ -1321,11 +1591,11 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"hyper",
"ipnet",
@@ -1493,6 +1763,15 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1515,6 +1794,33 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "isahc"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
+dependencies = [
+ "async-channel",
+ "castaway",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "encoding_rs",
+ "event-listener 2.5.3",
+ "futures-lite 1.13.0",
+ "http 0.2.12",
+ "log",
+ "mime",
+ "once_cell",
+ "polling",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1537,23 +1843,63 @@ version = "10.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [
- "base64",
+ "base64 0.22.1",
"ed25519-dalek",
"getrandom 0.2.17",
"hmac",
"js-sys",
"p256",
"p384",
- "pem",
+ "pem 3.0.6",
"rand",
- "rsa",
+ "rsa 0.9.10",
"serde",
"serde_json",
"sha2",
- "signature",
+ "signature 2.2.0",
"simple_asn1",
]
+[[package]]
+name = "jwt-simple"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357892bb32159d763abdea50733fadcb9a8e1c319a9aa77592db8555d05af83e"
+dependencies = [
+ "anyhow",
+ "binstring",
+ "coarsetime",
+ "ct-codecs",
+ "ed25519-compact",
+ "hmac-sha1-compact",
+ "hmac-sha256",
+ "hmac-sha512",
+ "k256",
+ "p256",
+ "p384",
+ "rand",
+ "rsa 0.7.2",
+ "serde",
+ "serde_json",
+ "spki 0.6.0",
+ "thiserror 1.0.69",
+ "zeroize",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature 2.2.0",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1575,13 +1921,23 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+[[package]]
+name = "libnghttp2-sys"
+version = "0.1.11+1.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b6c24e48a7167cffa7119da39d577fa482e66c688a4aac016bee862e1a713c4"
+dependencies = [
+ "cc",
+ "libc",
+]
+
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"libc",
"redox_syscall 0.7.0",
]
@@ -1596,6 +1952,18 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "libz-sys"
+version = "1.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -1687,7 +2055,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
- "http",
+ "http 1.4.0",
"httparse",
"memchr",
"mime",
@@ -1712,6 +2080,15 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -1803,7 +2180,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"cfg-if",
"foreign-types",
"libc",
@@ -1927,16 +2304,36 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
+dependencies = [
+ "base64 0.13.1",
+ "once_cell",
+ "regex",
+]
+
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
- "base64",
+ "base64 0.22.1",
"serde_core",
]
+[[package]]
+name = "pem-rfc7468"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -1979,6 +2376,26 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "pin-project"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1991,15 +2408,37 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+[[package]]
+name = "pkcs1"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff33bdbdfc54cc98a2eca766ebdec3e1b8fb7387523d5c9c9a2891da856f719"
+dependencies = [
+ "der 0.6.1",
+ "pkcs8 0.9.0",
+ "spki 0.6.0",
+ "zeroize",
+]
+
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
- "der",
- "pkcs8",
- "spki",
+ "der 0.7.10",
+ "pkcs8 0.10.2",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba"
+dependencies = [
+ "der 0.6.1",
+ "spki 0.6.0",
]
[[package]]
@@ -2008,8 +2447,8 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "der",
- "spki",
+ "der 0.7.10",
+ "spki 0.7.3",
]
[[package]]
@@ -2018,6 +2457,22 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "polling"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "concurrent-queue",
+ "libc",
+ "log",
+ "pin-project-lite",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2181,7 +2636,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
]
[[package]]
@@ -2190,7 +2645,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
]
[[package]]
@@ -2237,12 +2692,12 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -2324,22 +2779,43 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "rsa"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "094052d5470cbcef561cb848a7209968c9f12dfa6d668f4bca048ac5de51099c"
+dependencies = [
+ "byteorder",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "pkcs1 0.4.1",
+ "pkcs8 0.9.0",
+ "rand_core",
+ "signature 1.6.4",
+ "smallvec",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
- "pkcs1",
- "pkcs8",
+ "pkcs1 0.7.5",
+ "pkcs8 0.10.2",
"rand_core",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
"subtle",
"zeroize",
]
@@ -2375,7 +2851,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
@@ -2479,7 +2955,7 @@ dependencies = [
"serde_json",
"sqlx",
"strum 0.26.3",
- "thiserror",
+ "thiserror 2.0.18",
"time",
"tracing",
"url",
@@ -2576,7 +3052,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
- "thiserror",
+ "thiserror 2.0.18",
]
[[package]]
@@ -2615,20 +3091,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
- "der",
+ "der 0.7.10",
"generic-array",
- "pkcs8",
+ "pkcs8 0.10.2",
"subtle",
"zeroize",
]
+[[package]]
+name = "sec1_decode"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23"
+dependencies = [
+ "der 0.4.5",
+ "pem 0.8.3",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -2764,6 +3251,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "1.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
[[package]]
name = "signature"
version = "2.2.0"
@@ -2788,7 +3285,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
- "thiserror",
+ "thiserror 2.0.18",
"time",
]
@@ -2804,6 +3301,17 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel",
+ "futures-core",
+ "futures-io",
+]
+
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -2832,6 +3340,16 @@ dependencies = [
"lock_api",
]
+[[package]]
+name = "spki"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b"
+dependencies = [
+ "base64ct",
+ "der 0.6.1",
+]
+
[[package]]
name = "spki"
version = "0.7.3"
@@ -2839,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.10",
]
[[package]]
@@ -2861,14 +3379,14 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bigdecimal",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
- "event-listener",
+ "event-listener 5.4.1",
"futures-core",
"futures-intrusive",
"futures-io",
@@ -2886,7 +3404,7 @@ dependencies = [
"serde_json",
"sha2",
"smallvec",
- "thiserror",
+ "thiserror 2.0.18",
"time",
"tokio",
"tokio-stream",
@@ -2941,9 +3459,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
- "base64",
+ "base64 0.22.1",
"bigdecimal",
- "bitflags",
+ "bitflags 2.10.0",
"byteorder",
"bytes",
"chrono",
@@ -2966,7 +3484,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"rand",
- "rsa",
+ "rsa 0.9.10",
"rust_decimal",
"serde",
"sha1",
@@ -2974,7 +3492,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.18",
"time",
"tracing",
"uuid",
@@ -2988,9 +3506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
- "base64",
+ "base64 0.22.1",
"bigdecimal",
- "bitflags",
+ "bitflags 2.10.0",
"byteorder",
"chrono",
"crc",
@@ -3017,7 +3535,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.18",
"time",
"tracing",
"uuid",
@@ -3044,7 +3562,7 @@ dependencies = [
"serde",
"serde_urlencoded",
"sqlx-core",
- "thiserror",
+ "thiserror 2.0.18",
"time",
"tracing",
"url",
@@ -3144,6 +3662,18 @@ dependencies = [
"futures-core",
]
+[[package]]
+name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "unicode-xid",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -3161,7 +3691,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"core-foundation",
"system-configuration-sys",
]
@@ -3188,20 +3718,40 @@ version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
- "fastrand",
+ "fastrand 2.3.0",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
]
[[package]]
@@ -3355,6 +3905,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3420,9 +3982,9 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"bytes",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"pin-project-lite",
@@ -3436,10 +3998,10 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
- "bitflags",
+ "bitflags 2.10.0",
"bytes",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"iri-string",
"pin-project-lite",
@@ -3490,6 +4052,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
]
[[package]]
@@ -3499,12 +4083,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
+ "nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
+ "smallvec",
"thread_local",
"tracing",
"tracing-core",
+ "tracing-log",
]
[[package]]
@@ -3513,6 +4100,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http 1.4.0",
+ "httparse",
+ "log",
+ "rand",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "typenum"
version = "1.19.0"
@@ -3570,6 +4175,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3594,6 +4205,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -3606,6 +4223,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "waker-fn"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
+
[[package]]
name = "want"
version = "0.3.1"
@@ -3636,6 +4259,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+[[package]]
+name = "wasix"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332"
+dependencies = [
+ "wasi",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -3695,6 +4327,28 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "web-push"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f2332e5400bb42c21bcab3ca2cd3400ab4b1d5ecbe276b533ce9acb59c56602"
+dependencies = [
+ "async-trait",
+ "base64 0.13.1",
+ "chrono",
+ "ece",
+ "futures-lite 2.6.1",
+ "http 0.2.12",
+ "isahc",
+ "jwt-simple",
+ "log",
+ "pem 3.0.6",
+ "sec1_decode",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -3821,6 +4475,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -4081,7 +4744,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
- "synstructure",
+ "synstructure 0.13.2",
]
[[package]]
@@ -4122,7 +4785,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
- "synstructure",
+ "synstructure 0.13.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index e995879..9878cd9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,8 +12,8 @@ tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
-axum = "0.7"
-tower-http = { version = "0.5", features = ["cors"] }
+axum = { version = "0.7", features = ["ws"] }
+tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = "1.1"
uuid = { version = "1.8", features = ["v4", "serde"] }
@@ -22,4 +22,11 @@ tokio-cron-scheduler = "0.15.1"
dashmap = "6.1.0"
jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
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"] }
diff --git a/frontend/index.html b/frontend/index.html
index 6567fa6..edcefbf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,9 +3,13 @@
-
+
+
+
+
+
+
Antigravity Agent Dashboard
-
+
+
+
+
⌘
@@ -63,22 +75,56 @@
-
-
-
-
- | Directive |
- Status |
- Date |
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Establish connection with the neural assistant.
+
+
+
+
+
+
+
+
+
+
+
+ | Directive |
+ Status |
+ Date |
+
+
+
+
+
+
+
+
@@ -98,6 +144,9 @@
+
@@ -175,6 +224,7 @@
+
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
new file mode 100644
index 0000000..a5ea0e4
Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png
new file mode 100644
index 0000000..b23b3c7
Binary files /dev/null and b/frontend/public/icon-192.png differ
diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png
new file mode 100644
index 0000000..2905528
Binary files /dev/null and b/frontend/public/icon-512.png differ
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..5978a0c
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..e976c34
--- /dev/null
+++ b/frontend/public/sw.js
@@ -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);
+ }
+ })
+ );
+});
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 3e5100f..ffae909 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -1,5 +1,6 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
+import './style.css';
const API_URL = '/api';
// These should ideally be environment-specific
@@ -16,10 +17,11 @@ const state = {
selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false,
- token: localStorage.getItem('auth_token'),
- refreshToken: localStorage.getItem('refresh_token')
+ isAuthenticated: false,
+ chatMessages: [],
+ activeDashboardTab: 'chat', // 'chat' or 'activity'
+ swRegistration: null
};
-
// DOM elements
const loginOverlay = document.getElementById('login-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 customCronContainer = document.getElementById('custom-cron-container');
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 = `
+ ${icons[type] || 'ℹ'}
+ ${message}
+ `;
+
+ 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
async function fetchWithAuth(url, options = {}) {
- if (!state.token) {
- showLogin();
- throw new Error('Not authenticated');
- }
+ let response = await fetch(url, { ...options, credentials: 'include' });
- const headers = {
- ...options.headers,
- 'Authorization': `Bearer ${state.token}`
- };
-
- let response = await fetch(url, { ...options, headers });
-
- if (response.status === 401 && state.refreshToken) {
+ if (response.status === 401) {
// Try to refresh token
try {
const success = await attemptTokenRefresh();
if (success) {
- // Retry original request with new token
- const newHeaders = {
- ...options.headers,
- 'Authorization': `Bearer ${state.token}`
- };
- response = await fetch(url, { ...options, headers: newHeaders });
+ // Retry original request
+ response = await fetch(url, { ...options, credentials: 'include' });
}
} catch (error) {
console.error('Token refresh failed:', error);
@@ -100,65 +141,59 @@ async function attemptTokenRefresh() {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ refresh_token: state.refreshToken })
+ credentials: 'include',
+ body: JSON.stringify({ refresh_token: '' })
});
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) {
console.error('Error during token refresh:', error);
}
return false;
}
-
+// I'll replace the fetchTasks function and add updateState
async function fetchTasks() {
try {
const response = await fetchWithAuth(`${API_URL}/tasks`);
const newTasks = await response.json();
- // Check if we should follow the latest run (if we were already watching it)
- let shouldFollowLatest = false;
+ // Check for deep link in URL
+ const params = new URLSearchParams(window.location.search);
+ const urlTaskId = params.get('taskId');
+ const urlRunId = params.get('runId');
+
+ if (urlTaskId && !state.selectedTaskId) {
+ state.selectedTaskId = urlTaskId;
+ state.selectedRunId = urlRunId;
+ }
+
+ // Check if we should follow the latest run
+ let newSelectedRunId = state.selectedRunId;
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) {
- const latestRunId = currentTask.runs[currentTask.runs.length - 1].id;
- if (state.selectedRunId === latestRunId) {
- shouldFollowLatest = true;
+ // If we don't have a selected run or the runs changed, we might want to update
+ if (!state.selectedRunId || (state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.length !== currentTask.runs.length)) {
+ // 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;
- renderTaskList();
+ updateState({
+ tasks: newTasks,
+ selectedRunId: newSelectedRunId
+ });
- // If we are on the dashboard, refresh it too
- if (state.currentView === 'dashboard') {
- fetchRecentRuns();
- }
-
- // 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);
- }
+ // If we just loaded from a deep link, clear the params and select it
+ if (urlTaskId) {
+ window.history.replaceState({}, document.title, "/");
+ selectTask(urlTaskId, urlRunId);
}
} catch (error) {
console.error('Error fetching tasks:', error);
@@ -177,13 +212,7 @@ async function fetchRecentRuns() {
function renderTaskList() {
const sortedTasks = [...state.tasks].sort((a, b) => {
- const aDate = a.runs && a.runs.length > 0
- ? 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;
+ return new Date(b.created_at) - new Date(a.created_at);
});
taskListEl.innerHTML = sortedTasks
@@ -223,8 +252,8 @@ function selectTask(id, runId = null) {
if (runId) {
state.selectedRunId = runId;
} else if (task.runs && task.runs.length > 0) {
- // Default to latest run if not specified
- state.selectedRunId = task.runs[task.runs.length - 1].id;
+ // Default to latest run if not specified (index 0 is newest)
+ state.selectedRunId = task.runs[0].id;
} else {
state.selectedRunId = null;
}
@@ -232,6 +261,24 @@ function selectTask(id, runId = null) {
renderTaskList();
renderRunHistory(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) {
@@ -264,6 +311,28 @@ function showDashboard() {
renderTaskList();
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) {
@@ -354,6 +423,62 @@ function escapeHtml(text) {
return div.innerHTML;
}
+function renderChat() {
+ if (!chatMessagesEl) return;
+
+ if (state.chatMessages.length === 0) {
+ chatMessagesEl.innerHTML = `
+
+
Start a conversation with the assistant.
+
+ `;
+ return;
+ }
+
+ chatMessagesEl.innerHTML = state.chatMessages
+ .map(msg => `
+
+ ${DOMPurify.sanitize(marked.parse(msg.content || ''))}
+
+ `)
+ .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
rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return;
@@ -368,9 +493,10 @@ rerunBtn.addEventListener('click', async () => {
state.tasks[index] = updatedTask;
}
selectTask(updatedTask.id);
+ showToast('Task rerun successfully!', 'success');
} catch (error) {
- console.error('Error running task:', error);
- alert('Failed to run task.');
+ console.error('Failed to rerun task:', error);
+ showToast('Failed to rerun task.', 'error');
}
});
@@ -446,6 +572,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
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', () => {
// If user types manually, update presets active state
updateScheduleUI(cronInput.value);
@@ -489,23 +628,107 @@ newTaskForm.addEventListener('submit', async (e) => {
state.isEditing = false;
selectTask(updatedTask.id);
renderTaskList();
+ showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
} catch (error) {
- console.error('Error creating task:', error);
- alert('Failed to execute task. Check console.');
+ console.error('Save task failed:', error);
+ showToast('Failed to execute task. Check console.', 'error');
}
});
-let isPolling = false;
-async function startAutoRefresh() {
- setInterval(async () => {
- if (isPolling) return;
- isPolling = true;
- try {
- await fetchTasks();
- } finally {
- isPolling = false;
+async function checkSession() {
+ try {
+ const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' });
+ if (response.ok) {
+ 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() {
@@ -514,10 +737,12 @@ async function showLogin() {
}
async function logout() {
- state.token = null;
- state.refreshToken = null;
- localStorage.removeItem('auth_token');
- localStorage.removeItem('refresh_token');
+ try {
+ await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
+ } catch (error) {
+ console.error('Logout failed:', error);
+ }
+ state.isAuthenticated = false;
showLogin();
}
@@ -531,16 +756,20 @@ async function handleCallback() {
loginOverlay.classList.add('hidden');
try {
- const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`);
- const data = await response.json();
+ const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, {
+ credentials: 'include'
+ });
+ const contentType = response.headers.get("content-type");
+ let data;
+ if (contentType && contentType.includes("application/json")) {
+ data = await response.json();
+ } else {
+ const text = await response.text();
+ throw new Error(`Expected JSON but got ${contentType}. Body: ${text.substring(0, 100)}`);
+ }
- if (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);
- }
+ if (response.ok) {
+ state.isAuthenticated = true;
callbackOverlay.classList.add('hidden');
appEl.classList.remove('hidden');
initializeApp();
@@ -548,8 +777,8 @@ async function handleCallback() {
throw new Error('No access token in response');
}
} catch (error) {
- console.error('Auth callback failed:', error);
- alert('Authentication failed.');
+ console.error('Callback failed:', error);
+ showToast('Authentication failed.', 'error');
showLogin();
}
}
@@ -563,17 +792,51 @@ logoutBtn.addEventListener('click', () => {
logout();
});
+document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ state.activeDashboardTab = btn.dataset.tab;
+ renderDashboardTabs();
+ });
+});
+
+function toggleMobileMenu() {
+ sidebarEl.classList.toggle('open');
+ menuToggle.classList.toggle('active');
+ sidebarOverlay.classList.toggle('hidden');
+ document.body.style.overflow = sidebarEl.classList.contains('open') ? 'hidden' : '';
+}
+
+function closeMobileMenu() {
+ sidebarEl.classList.remove('open');
+ menuToggle.classList.remove('active');
+ sidebarOverlay.classList.add('hidden');
+ document.body.style.overflow = '';
+}
+
+menuToggle.addEventListener('click', toggleMobileMenu);
+sidebarOverlay.addEventListener('click', closeMobileMenu);
+
async function initializeApp() {
- if (!state.token) {
+ const hasSession = await checkSession();
+
+ if (hasSession) {
+ appEl.classList.remove('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();
+ connectWebSocket();
+ } else {
showLogin();
- return;
}
-
- appEl.classList.remove('hidden');
- loginOverlay.classList.add('hidden');
-
- await fetchTasks();
- startAutoRefresh();
}
// Check for callback on load
@@ -582,3 +845,116 @@ if (window.location.pathname === '/callback' || window.location.search.includes(
} else {
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);
+});
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 742bc21..c2c08b6 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -25,6 +25,7 @@ body {
color: var(--text-main);
line-height: 1.5;
height: 100vh;
+ height: 100dvh;
overflow: hidden;
}
@@ -88,9 +89,15 @@ body {
.dashboard-content {
flex: 1;
overflow-y: auto;
- border-radius: 12px;
+ border-radius: 16px;
display: flex;
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 {
@@ -124,6 +131,158 @@ body {
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 {
display: inline-block;
}
@@ -796,8 +955,79 @@ textarea:focus {
}
.btn-sm {
- padding: 6px 12px;
+ padding: 6px 16px;
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 {
@@ -814,4 +1044,242 @@ textarea:focus {
.logout-btn:hover {
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;
+ }
}
\ No newline at end of file
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 7ce721b..a06ef36 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -4,8 +4,9 @@ export default defineConfig({
server: {
proxy: {
'/api': {
- target: 'http://localhost:3000',
+ target: 'http://localhost:3001',
changeOrigin: true,
+ ws: true,
}
}
}
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index e206140..cad08e0 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -4,6 +4,7 @@ mod m20220101_000001_create_table;
mod m20260210_000002_add_answer_column;
mod m20260210_000003_separate_runs;
mod m20260210_000004_add_cron_column;
+mod m20260212_000005_notifications;
pub struct Migrator;
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260210_000002_add_answer_column::Migration),
Box::new(m20260210_000003_separate_runs::Migration),
Box::new(m20260210_000004_add_cron_column::Migration),
+ Box::new(m20260212_000005_notifications::Migration),
]
}
}
diff --git a/migration/src/m20260212_000005_notifications.rs b/migration/src/m20260212_000005_notifications.rs
new file mode 100644
index 0000000..fdfdaac
--- /dev/null
+++ b/migration/src/m20260212_000005_notifications.rs
@@ -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,
+}
diff --git a/openapi/calendar/openapi.json b/openapi/calendar/openapi.json
new file mode 100644
index 0000000..8c8908c
--- /dev/null
+++ b/openapi/calendar/openapi.json
@@ -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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/agent.rs b/src/agent.rs
deleted file mode 100644
index 2bbc042..0000000
--- a/src/agent.rs
+++ /dev/null
@@ -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,
- tavily_api_key: Option,
- messages: Vec,
- tools: Option>,
- logs: String,
- answer: Option,
-}
-
-impl Agent {
- pub fn new(
- zen_api_key: Option,
- tavily_api_key: Option,
- initial_message: String,
- ) -> Result> {
- 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), Box> {
- 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()))
- }
-}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..b81a727
--- /dev/null
+++ b/src/config.rs
@@ -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,
+ pub tavily_api_key: Option,
+ pub authentik_issuer: String,
+ pub authentik_client_id: String,
+ pub authentik_client_secret: String,
+ pub cors_allowed_origins: Option,
+ 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 {
+ 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,
+ })
+ }
+}
diff --git a/src/api.rs b/src/domain/agent/api.rs
similarity index 91%
rename from src/api.rs
rename to src/domain/agent/api.rs
index 2d2ae37..cbd93a8 100644
--- a/src/api.rs
+++ b/src/domain/agent/api.rs
@@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message {
pub role: String,
- #[serde(skip_serializing_if = "Option::is_none")]
pub content: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option>,
@@ -29,6 +28,7 @@ pub struct FunctionCall {
pub struct ChatRequest {
pub model: String,
pub messages: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option>,
}
@@ -72,8 +72,11 @@ pub async fn perform_search(
query: &str,
api_key: &str,
) -> Result> {
+ tracing::info!(query = %query, "Performing Tavily web search");
let client = reqwest::Client::builder()
.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()?;
let response = client
.post("https://api.tavily.com/search")
@@ -94,6 +97,7 @@ pub async fn perform_search(
}
let search_data: TavilyResponse = response.json().await?;
+ tracing::info!("Web search yielded {} results", search_data.results.len());
let mut results_text = String::new();
for (i, result) in search_data.results.iter().enumerate() {
diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs
new file mode 100644
index 0000000..8f738cc
--- /dev/null
+++ b/src/domain/agent/mod.rs
@@ -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,
+ tavily_api_key: Option,
+ calendar_client: Arc,
+ pub user_sub: Option,
+ pub messages: Vec,
+ tools: Option>,
+ logs: String,
+ answer: Option,
+}
+
+use crate::error::{AppError, AppResult};
+
+impl Agent {
+ pub fn new(
+ db: DatabaseConnection,
+ zen_api_key: Option,
+ tavily_api_key: Option,
+ calendar_client: Arc,
+ user_sub: Option,
+ initial_message: String,
+ ) -> AppResult {
+ 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,
+ tavily_api_key: Option,
+ calendar_client: Arc,
+ user_sub: Option,
+ messages: Vec,
+ ) -> AppResult {
+ 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)> {
+ 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 {
+ 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 {
+ 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)
+ })
+ }
+}
diff --git a/src/domain/agent/tools.rs b/src/domain/agent/tools.rs
new file mode 100644
index 0000000..2b257c0
--- /dev/null
+++ b/src/domain/agent/tools.rs
@@ -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,
+ to: Option,
+ task_ids: Option>,
+}
+
+pub fn get_tools() -> Vec {
+ 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,
+ db: &DatabaseConnection,
+ calendar: &Arc,
+ user_sub: Option<&str>,
+) -> Result<(Message, bool, Option), Box> {
+ 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::() {
+ 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,
+ ))
+}
diff --git a/src/auth.rs b/src/domain/auth.rs
similarity index 69%
rename from src/auth.rs
rename to src/domain/auth.rs
index 0cf0f67..a180185 100644
--- a/src/auth.rs
+++ b/src/domain/auth.rs
@@ -10,10 +10,17 @@ pub struct Claims {
pub exp: usize,
pub iat: usize,
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),
+}
+
+#[derive(Debug, Deserialize, Clone)]
struct Jwk {
#[serde(rename = "kty")]
_kty: String,
@@ -31,15 +38,18 @@ struct Jwks {
pub struct JwksVerifier {
issuer: String,
+ audience: String,
jwks_uri: String,
keys: Arc>>,
client: Client,
}
impl JwksVerifier {
- pub async fn new(issuer: String) -> Result> {
- let client = Client::new();
- // Authentik OIDC discovery
+ pub async fn new(issuer: String, audience: String) -> Result> {
+ let client = Client::builder()
+ .timeout(std::time::Duration::from_secs(30))
+ .connect_timeout(std::time::Duration::from_secs(10))
+ .build()?;
let discovery_url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
@@ -53,6 +63,7 @@ impl JwksVerifier {
let verifier = Self {
issuer,
+ audience,
jwks_uri,
keys: Arc::new(RwLock::new(Vec::new())),
client,
@@ -73,18 +84,29 @@ impl JwksVerifier {
let header = decode_header(token)?;
let kid = header.kid.ok_or("Missing kid in token header")?;
- let keys = self.keys.read().await;
- let jwk = keys
- .iter()
- .find(|k| k.kid == kid)
- .ok_or("Key not found in JWKS")?;
+ let jwk = {
+ let keys = self.keys.read().await;
+ keys.iter().find(|k| k.kid == kid).cloned()
+ };
+
+ 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)
+ .cloned()
+ .ok_or("Key not found in JWKS")?
+ }
+ };
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[self.issuer.clone()]);
- // Aud validation might need careful config, usually it's the client_id
- validation.validate_aud = false;
+ validation.set_audience(&[self.audience.clone()]);
+ validation.validate_aud = true;
let token_data = decode::(token, &decoding_key, &validation)?;
Ok(token_data.claims)
@@ -104,7 +126,10 @@ impl Authenticator {
client_id: String,
client_secret: String,
) -> Result> {
- 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!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
@@ -171,4 +196,27 @@ impl Authenticator {
Ok(res)
}
+
+ pub async fn client_credentials(
+ &self,
+ scope: &str,
+ ) -> Result> {
+ 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)
+ }
}
diff --git a/src/domain/calendar/mod.rs b/src/domain/calendar/mod.rs
new file mode 100644
index 0000000..c6f49ae
--- /dev/null
+++ b/src/domain/calendar/mod.rs
@@ -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,
+ pub name: String,
+ pub from: DateTime,
+ pub to: DateTime,
+ pub user_sub: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CreateEventRequest {
+ pub name: String,
+ pub from: String,
+ pub to: String,
+ pub user_sub: Option,
+}
+struct TokenState {
+ access_token: String,
+ expires_at: DateTime,
+}
+
+pub struct CalendarClient {
+ base_url: String,
+ client: Client,
+ authenticator: Arc,
+ token_state: RwLock