diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index 735c2ef..fdcd8f5 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -11,31 +11,6 @@ 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 9bf488d..771536c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ /target node_modules -/frontend/dist -.env \ No newline at end of file +/frontend/dist \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 907bd9b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,154 +0,0 @@ -# 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 b8d0c9c..1a9f8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,29 +93,12 @@ 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" @@ -178,10 +161,9 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64 0.22.1", "bytes", "futures-util", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -197,10 +179,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -216,7 +196,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http", "http-body", "http-body-util", "mime", @@ -237,11 +217,10 @@ dependencies = [ "axum", "axum-core", "bytes", - "cookie", - "fastrand 2.3.0", + "fastrand", "futures-util", "headers", - "http 1.4.0", + "http", "http-body", "http-body-util", "mime", @@ -259,18 +238,6 @@ 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" @@ -297,18 +264,6 @@ 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" @@ -368,12 +323,9 @@ version = "0.1.0" dependencies = [ "axum", "axum-extra", - "base64 0.22.1", + "base64", "chrono", - "cookie", "dashmap", - "dotenvy", - "isahc", "jsonwebtoken", "migration", "reqwest", @@ -381,14 +333,10 @@ 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]] @@ -431,12 +379,6 @@ 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" @@ -523,17 +465,6 @@ 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" @@ -549,29 +480,12 @@ 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" @@ -660,43 +574,6 @@ 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" @@ -773,56 +650,17 @@ 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 0.9.6", - "pem-rfc7468 0.7.0", + "const-oid", + "pem-rfc7468", "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" @@ -893,7 +731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid 0.9.6", + "const-oid", "crypto-common", "subtle", ] @@ -921,30 +759,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest", "elliptic-curve", "rfc6979", - "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", + "signature", + "spki", ] [[package]] @@ -953,18 +773,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "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", + "pkcs8", + "signature", ] [[package]] @@ -1003,8 +813,8 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pem-rfc7468", + "pkcs8", "rand_core", "sec1", "subtle", @@ -1047,12 +857,6 @@ 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" @@ -1064,15 +868,6 @@ 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" @@ -1212,34 +1007,6 @@ 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" @@ -1297,11 +1064,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1332,7 +1097,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http", "indexmap", "slab", "tokio", @@ -1387,10 +1152,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", - "http 1.4.0", + "http", "httpdate", "mime", "sha1", @@ -1402,7 +1167,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http", ] [[package]] @@ -1441,30 +1206,6 @@ 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" @@ -1474,17 +1215,6 @@ 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" @@ -1502,7 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -1513,7 +1243,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http", "http-body", "pin-project-lite", ] @@ -1541,7 +1271,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http", "http-body", "httparse", "httpdate", @@ -1559,7 +1289,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", + "http", "hyper", "hyper-util", "rustls", @@ -1591,11 +1321,11 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http", "http-body", "hyper", "ipnet", @@ -1763,15 +1493,6 @@ 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" @@ -1794,33 +1515,6 @@ 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" @@ -1843,63 +1537,23 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "base64 0.22.1", + "base64", "ed25519-dalek", "getrandom 0.2.17", "hmac", "js-sys", "p256", "p384", - "pem 3.0.6", + "pem", "rand", - "rsa 0.9.10", + "rsa", "serde", "serde_json", "sha2", - "signature 2.2.0", + "signature", "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" @@ -1921,23 +1575,13 @@ 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 2.10.0", + "bitflags", "libc", "redox_syscall 0.7.0", ] @@ -1952,18 +1596,6 @@ 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" @@ -2055,7 +1687,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http", "httparse", "memchr", "mime", @@ -2080,15 +1712,6 @@ 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" @@ -2180,7 +1803,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2304,36 +1927,16 @@ 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 0.22.1", + "base64", "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" @@ -2376,26 +1979,6 @@ 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" @@ -2408,37 +1991,15 @@ 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 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", + "der", + "pkcs8", + "spki", ] [[package]] @@ -2447,8 +2008,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", ] [[package]] @@ -2457,22 +2018,6 @@ 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" @@ -2636,7 +2181,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -2645,7 +2190,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -2692,12 +2237,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", "h2", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -2779,43 +2324,22 @@ 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 0.9.6", + "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", - "pkcs1 0.7.5", - "pkcs8 0.10.2", + "pkcs1", + "pkcs8", "rand_core", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", "subtle", "zeroize", ] @@ -2851,7 +2375,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2955,7 +2479,7 @@ dependencies = [ "serde_json", "sqlx", "strum 0.26.3", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "url", @@ -3052,7 +2576,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -3091,31 +2615,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.7.10", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "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 2.10.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -3251,16 +2764,6 @@ 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" @@ -3285,7 +2788,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -3301,17 +2804,6 @@ 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" @@ -3340,16 +2832,6 @@ 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" @@ -3357,7 +2839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -3379,14 +2861,14 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64 0.22.1", + "base64", "bigdecimal", "bytes", "chrono", "crc", "crossbeam-queue", "either", - "event-listener 5.4.1", + "event-listener", "futures-core", "futures-intrusive", "futures-io", @@ -3404,7 +2886,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tokio-stream", @@ -3459,9 +2941,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bigdecimal", - "bitflags 2.10.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3484,7 +2966,7 @@ dependencies = [ "once_cell", "percent-encoding", "rand", - "rsa 0.9.10", + "rsa", "rust_decimal", "serde", "sha1", @@ -3492,7 +2974,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "uuid", @@ -3506,9 +2988,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bigdecimal", - "bitflags 2.10.0", + "bitflags", "byteorder", "chrono", "crc", @@ -3535,7 +3017,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "uuid", @@ -3562,7 +3044,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "url", @@ -3662,18 +3144,6 @@ 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" @@ -3691,7 +3161,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -3718,40 +3188,20 @@ version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "fastrand 2.3.0", + "fastrand", "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 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", + "thiserror-impl", ] [[package]] @@ -3905,18 +3355,6 @@ 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" @@ -3982,9 +3420,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", - "http 1.4.0", + "http", "http-body", "http-body-util", "pin-project-lite", @@ -3998,10 +3436,10 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http", "http-body", "iri-string", "pin-project-lite", @@ -4052,28 +3490,6 @@ 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]] @@ -4083,15 +3499,12 @@ 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]] @@ -4100,24 +3513,6 @@ 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" @@ -4175,12 +3570,6 @@ 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" @@ -4205,12 +3594,6 @@ 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" @@ -4223,12 +3606,6 @@ 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" @@ -4259,15 +3636,6 @@ 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" @@ -4327,28 +3695,6 @@ 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" @@ -4475,15 +3821,6 @@ 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" @@ -4744,7 +4081,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure 0.13.2", + "synstructure", ] [[package]] @@ -4785,7 +4122,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure 0.13.2", + "synstructure", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9878cd9..e995879 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 = { version = "0.7", features = ["ws"] } -tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] } +axum = "0.7" +tower-http = { version = "0.5", features = ["cors"] } 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,11 +22,4 @@ tokio-cron-scheduler = "0.15.1" dashmap = "6.1.0" jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] } base64 = "0.22.1" -web-push = { version = "0.10.0", features = ["isahc-client"] } -isahc = "1.7" -axum-extra = { version = "0.9", features = ["typed-header", "cookie"] } -cookie = "0.18" -thiserror = "2.0.18" -dotenvy = "0.15.7" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum-extra = { version = "0.9", features = ["typed-header"] } diff --git a/frontend/index.html b/frontend/index.html index edcefbf..6567fa6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,13 +3,9 @@ - - - - - - + Antigravity Agent Dashboard + -
- - -
⌘
@@ -75,56 +63,22 @@
-

Perspective

-

Overview of recent agent directives and live communication.

- -
- - -
+

Recent Activity

+

Track latest agent executions across all directives.

- -
-
-
-
-

Neural Link

- -
-
- -
-

Establish connection with the neural assistant.

-
-
-
- - -
-
-
- -
-
- - - - - - - - - - - -
DirectiveStatusDate
-
-
+
+ + + + + + + + + + + +
DirectiveStatusDate
@@ -144,9 +98,6 @@ -
@@ -224,7 +175,6 @@
-
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png deleted file mode 100644 index a5ea0e4..0000000 Binary files a/frontend/public/apple-touch-icon.png and /dev/null differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png deleted file mode 100644 index b23b3c7..0000000 Binary files a/frontend/public/icon-192.png and /dev/null differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png deleted file mode 100644 index 2905528..0000000 Binary files a/frontend/public/icon-512.png and /dev/null differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json deleted file mode 100644 index 5978a0c..0000000 --- a/frontend/public/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index e976c34..0000000 --- a/frontend/public/sw.js +++ /dev/null @@ -1,119 +0,0 @@ -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 ffae909..3e5100f 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,5 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; -import './style.css'; const API_URL = '/api'; // These should ideally be environment-specific @@ -17,11 +16,10 @@ const state = { selectedRunId: null, currentView: 'dashboard', // 'dashboard' or 'task' isEditing: false, - isAuthenticated: false, - chatMessages: [], - activeDashboardTab: 'chat', // 'chat' or 'activity' - swRegistration: null + token: localStorage.getItem('auth_token'), + refreshToken: localStorage.getItem('refresh_token') }; + // DOM elements const loginOverlay = document.getElementById('login-overlay'); const callbackOverlay = document.getElementById('callback-overlay'); @@ -57,71 +55,32 @@ 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 = {}) { - let response = await fetch(url, { ...options, credentials: 'include' }); + if (!state.token) { + showLogin(); + throw new Error('Not authenticated'); + } - if (response.status === 401) { + const headers = { + ...options.headers, + 'Authorization': `Bearer ${state.token}` + }; + + let response = await fetch(url, { ...options, headers }); + + if (response.status === 401 && state.refreshToken) { // Try to refresh token try { const success = await attemptTokenRefresh(); if (success) { - // Retry original request - response = await fetch(url, { ...options, credentials: 'include' }); + // Retry original request with new token + const newHeaders = { + ...options.headers, + 'Authorization': `Bearer ${state.token}` + }; + response = await fetch(url, { ...options, headers: newHeaders }); } } catch (error) { console.error('Token refresh failed:', error); @@ -141,59 +100,65 @@ async function attemptTokenRefresh() { const response = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ refresh_token: '' }) + body: JSON.stringify({ refresh_token: state.refreshToken }) }); if (response.ok) { - return true; + 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; + } } } 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 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; + // Check if we should follow the latest run (if we were already watching it) + let shouldFollowLatest = false; if (state.selectedTaskId) { - const currentTask = newTasks.find(t => t.id === state.selectedTaskId); + const currentTask = state.tasks.find(t => t.id === state.selectedTaskId); if (currentTask && currentTask.runs && currentTask.runs.length > 0) { - // 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; - } + const latestRunId = currentTask.runs[currentTask.runs.length - 1].id; + if (state.selectedRunId === latestRunId) { + shouldFollowLatest = true; } + } else if (!state.selectedRunId) { + shouldFollowLatest = true; } } - updateState({ - tasks: newTasks, - selectedRunId: newSelectedRunId - }); + state.tasks = newTasks; + renderTaskList(); - // If we just loaded from a deep link, clear the params and select it - if (urlTaskId) { - window.history.replaceState({}, document.title, "/"); - selectTask(urlTaskId, urlRunId); + // 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); + } } } catch (error) { console.error('Error fetching tasks:', error); @@ -212,7 +177,13 @@ async function fetchRecentRuns() { function renderTaskList() { const sortedTasks = [...state.tasks].sort((a, b) => { - return new Date(b.created_at) - new Date(a.created_at); + 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; }); taskListEl.innerHTML = sortedTasks @@ -252,8 +223,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 (index 0 is newest) - state.selectedRunId = task.runs[0].id; + // Default to latest run if not specified + state.selectedRunId = task.runs[task.runs.length - 1].id; } else { state.selectedRunId = null; } @@ -261,24 +232,6 @@ 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) { @@ -311,28 +264,6 @@ 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) { @@ -423,62 +354,6 @@ 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; @@ -493,10 +368,9 @@ rerunBtn.addEventListener('click', async () => { state.tasks[index] = updatedTask; } selectTask(updatedTask.id); - showToast('Task rerun successfully!', 'success'); } catch (error) { - console.error('Failed to rerun task:', error); - showToast('Failed to rerun task.', 'error'); + console.error('Error running task:', error); + alert('Failed to run task.'); } }); @@ -572,19 +446,6 @@ 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); @@ -628,107 +489,23 @@ 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('Save task failed:', error); - showToast('Failed to execute task. Check console.', 'error'); + console.error('Error creating task:', error); + alert('Failed to execute task. Check console.'); } }); -async function checkSession() { - try { - const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' }); - if (response.ok) { - state.isAuthenticated = true; - return true; - } - } 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) => { +let isPolling = false; +async function startAutoRefresh() { + setInterval(async () => { + if (isPolling) return; + isPolling = true; 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); + await fetchTasks(); + } finally { + isPolling = false; } - }; - - 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(); - }; + }, 3000); } async function showLogin() { @@ -737,12 +514,10 @@ async function showLogin() { } async function logout() { - try { - await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' }); - } catch (error) { - console.error('Logout failed:', error); - } - state.isAuthenticated = false; + state.token = null; + state.refreshToken = null; + localStorage.removeItem('auth_token'); + localStorage.removeItem('refresh_token'); showLogin(); } @@ -756,20 +531,16 @@ async function handleCallback() { loginOverlay.classList.add('hidden'); try { - 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)}`); - } + const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`); + const data = await response.json(); - if (response.ok) { - state.isAuthenticated = true; + 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); + } callbackOverlay.classList.add('hidden'); appEl.classList.remove('hidden'); initializeApp(); @@ -777,8 +548,8 @@ async function handleCallback() { throw new Error('No access token in response'); } } catch (error) { - console.error('Callback failed:', error); - showToast('Authentication failed.', 'error'); + console.error('Auth callback failed:', error); + alert('Authentication failed.'); showLogin(); } } @@ -792,51 +563,17 @@ 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() { - 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 { + if (!state.token) { showLogin(); + return; } + + appEl.classList.remove('hidden'); + loginOverlay.classList.add('hidden'); + + await fetchTasks(); + startAutoRefresh(); } // Check for callback on load @@ -845,116 +582,3 @@ 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 c2c08b6..742bc21 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -25,7 +25,6 @@ body { color: var(--text-main); line-height: 1.5; height: 100vh; - height: 100dvh; overflow: hidden; } @@ -89,15 +88,9 @@ body { .dashboard-content { flex: 1; overflow-y: auto; - border-radius: 16px; + border-radius: 12px; 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 { @@ -131,158 +124,6 @@ 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; } @@ -955,79 +796,8 @@ textarea:focus { } .btn-sm { - padding: 6px 16px; + padding: 6px 12px; font-size: 12px; - width: auto; - justify-content: center; -} - -/* Toast System */ -#toast-container { - position: fixed; - bottom: 24px; - right: 24px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 2000; -} - -.toast { - min-width: 300px; - padding: 16px 20px; - border-radius: 12px; - background: var(--bg-sidebar); - border: 1px solid var(--glass-border); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - gap: 12px; - animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.toast.error { - border-left: 4px solid var(--status-failed); -} - -.toast.success { - border-left: 4px solid var(--status-completed); -} - -.toast.info { - border-left: 4px solid var(--primary); -} - -.toast-icon { - font-size: 18px; -} - -.toast-message { - font-size: 14px; - font-weight: 500; -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - - to { - transform: translateX(0); - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - transform: scale(1); - } - - to { - opacity: 0; - transform: scale(0.95); - } } .logout-btn { @@ -1044,242 +814,4 @@ 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 a06ef36..7ce721b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,9 +4,8 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:3001', + target: 'http://localhost:3000', changeOrigin: true, - ws: true, } } } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index cad08e0..e206140 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -4,7 +4,6 @@ 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; @@ -16,7 +15,6 @@ 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 deleted file mode 100644 index fdfdaac..0000000 --- a/migration/src/m20260212_000005_notifications.rs +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 8c8908c..0000000 --- a/openapi/calendar/openapi.json +++ /dev/null @@ -1,593 +0,0 @@ -{ - "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 new file mode 100644 index 0000000..2bbc042 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,146 @@ +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/domain/agent/api.rs b/src/api.rs similarity index 91% rename from src/domain/agent/api.rs rename to src/api.rs index cbd93a8..2d2ae37 100644 --- a/src/domain/agent/api.rs +++ b/src/api.rs @@ -3,6 +3,7 @@ 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>, @@ -28,7 +29,6 @@ pub struct FunctionCall { pub struct ChatRequest { pub model: String, pub messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, } @@ -72,11 +72,8 @@ 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") @@ -97,7 +94,6 @@ 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/auth.rs b/src/auth.rs similarity index 69% rename from src/domain/auth.rs rename to src/auth.rs index a180185..0cf0f67 100644 --- a/src/domain/auth.rs +++ b/src/auth.rs @@ -10,17 +10,10 @@ pub struct Claims { pub exp: usize, pub iat: usize, pub iss: String, - pub aud: Audience, + pub aud: String, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum Audience { - Single(String), - Multiple(Vec), -} - -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize)] struct Jwk { #[serde(rename = "kty")] _kty: String, @@ -38,18 +31,15 @@ struct Jwks { pub struct JwksVerifier { issuer: String, - audience: String, jwks_uri: String, keys: Arc>>, client: Client, } impl JwksVerifier { - 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()?; + pub async fn new(issuer: String) -> Result> { + let client = Client::new(); + // Authentik OIDC discovery let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') @@ -63,7 +53,6 @@ impl JwksVerifier { let verifier = Self { issuer, - audience, jwks_uri, keys: Arc::new(RwLock::new(Vec::new())), client, @@ -84,29 +73,18 @@ impl JwksVerifier { let header = decode_header(token)?; let kid = header.kid.ok_or("Missing kid in token header")?; - 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 keys = self.keys.read().await; + let jwk = keys + .iter() + .find(|k| k.kid == kid) + .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()]); - validation.set_audience(&[self.audience.clone()]); - validation.validate_aud = true; + // Aud validation might need careful config, usually it's the client_id + validation.validate_aud = false; let token_data = decode::(token, &decoding_key, &validation)?; Ok(token_data.claims) @@ -126,10 +104,7 @@ impl Authenticator { client_id: String, client_secret: String, ) -> Result> { - let client = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build()?; + let client = Client::new(); let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') @@ -196,27 +171,4 @@ 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/config.rs b/src/config.rs deleted file mode 100644 index b81a727..0000000 --- a/src/config.rs +++ /dev/null @@ -1,81 +0,0 @@ -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/domain/agent/mod.rs b/src/domain/agent/mod.rs deleted file mode 100644 index 8f738cc..0000000 --- a/src/domain/agent/mod.rs +++ /dev/null @@ -1,322 +0,0 @@ -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 deleted file mode 100644 index 2b257c0..0000000 --- a/src/domain/agent/tools.rs +++ /dev/null @@ -1,287 +0,0 @@ -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/domain/calendar/mod.rs b/src/domain/calendar/mod.rs deleted file mode 100644 index c6f49ae..0000000 --- a/src/domain/calendar/mod.rs +++ /dev/null @@ -1,298 +0,0 @@ -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>, -} - -impl CalendarClient { - pub fn new(base_url: String, authenticator: Arc) -> Self { - let client = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| Client::new()); - - Self { - base_url: base_url.trim_end_matches('/').to_string(), - client, - authenticator, - token_state: RwLock::new(None), - } - } - - async fn get_token(&self) -> AppResult { - { - let state = self.token_state.read().await; - if let Some(token) = &*state { - if token.expires_at > Utc::now() + Duration::seconds(30) { - tracing::debug!("Using cached Calendar API token"); - return Ok(token.access_token.clone()); - } - } - } - - let mut state = self.token_state.write().await; - // Double check after acquiring write lock - if let Some(token) = &*state { - if token.expires_at > Utc::now() + Duration::seconds(30) { - return Ok(token.access_token.clone()); - } - } - - tracing::info!("Refreshing Calendar API token via Client Credentials flow"); - let token_data = self - .authenticator - .client_credentials("profile") - .await - .map_err(|e| AppError::Internal(format!("Failed to get client credentials: {}", e)))?; - - let access_token = token_data["access_token"] - .as_str() - .ok_or_else(|| AppError::Internal("Missing access_token in response".into()))? - .to_string(); - - let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600); - - let expires_at = Utc::now() + Duration::seconds(expires_in); - - *state = Some(TokenState { - access_token: access_token.clone(), - expires_at, - }); - - Ok(access_token) - } - - pub async fn list_events( - &self, - user_sub: Option, - upcoming: Option, - ) -> AppResult> { - let token = self.get_token().await?; - let mut url = format!("{}/service/v1/events", self.base_url); - let mut params = Vec::new(); - if let Some(uid) = &user_sub { - params.push(format!("user_sub={}", uid)); - } - if let Some(u) = upcoming { - params.push(format!("upcoming={}", u)); - } - - if !params.is_empty() { - url.push_str("?"); - url.push_str(¶ms.join("&")); - } - - tracing::info!(method = "GET", %url, "Sending Calendar API request"); - - let res = self - .client - .get(&url) - .bearer_auth(token) - .send() - .await - .map_err(AppError::Network)?; - - let status = res.status(); - tracing::info!(%status, %url, "Received Calendar API response"); - - if !status.is_success() { - let error_body = res.text().await.unwrap_or_default(); - tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); - return Err(AppError::Internal(format!( - "Failed to list events: {} - {}", - status, error_body - ))); - } - - let body = res - .json() - .await - .map_err(|e| AppError::Internal(e.to_string())); - tracing::info!(%status, %url, body = ?body, "Received Calendar API response"); - body - } - - pub async fn create_event( - &self, - user_sub: Option, - name: &str, - from: &str, - to: &str, - ) -> AppResult { - let token = self.get_token().await?; - let url = format!("{}/service/v1/events", self.base_url); - - let request = CreateEventRequest { - name: name.to_string(), - from: from.to_string(), - to: to.to_string(), - user_sub, - }; - - tracing::info!(method = "POST", %url, "Sending Calendar API request"); - - let res = self - .client - .post(&url) - .bearer_auth(token) - .json(&request) - .send() - .await - .map_err(AppError::Network)?; - - let status = res.status(); - tracing::info!(%status, %url, "Received Calendar API response"); - - if !status.is_success() { - let error_body = res.text().await.unwrap_or_default(); - tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); - return Err(AppError::Internal(format!( - "Failed to create event: {} - {}", - status, error_body - ))); - } - - res.json() - .await - .map_err(|e| AppError::Internal(e.to_string())) - } - - #[allow(dead_code)] - pub async fn get_event(&self, id: i32) -> AppResult { - let token = self.get_token().await?; - let url = format!("{}/service/v1/events/{}", self.base_url, id); - tracing::info!(method = "GET", %url, "Sending Calendar API request"); - - let res = self - .client - .get(&url) - .bearer_auth(token) - .send() - .await - .map_err(AppError::Network)?; - - let status = res.status(); - tracing::info!(%status, %url, "Received Calendar API response"); - - if !status.is_success() { - let error_body = res.text().await.unwrap_or_default(); - tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); - return Err(AppError::Internal(format!( - "Failed to get event: {} - {}", - status, error_body - ))); - } - - res.json() - .await - .map_err(|e| AppError::Internal(e.to_string())) - } - - #[allow(dead_code)] - pub async fn update_event( - &self, - id: i32, - user_sub: Option, - name: &str, - from: &str, - to: &str, - ) -> AppResult { - let token = self.get_token().await?; - let url = format!("{}/service/v1/events/{}", self.base_url, id); - - let request = CreateEventRequest { - name: name.to_string(), - from: from.to_string(), - to: to.to_string(), - user_sub, - }; - - tracing::info!(method = "PUT", %url, "Sending Calendar API request"); - - let res = self - .client - .put(&url) - .bearer_auth(token) - .json(&request) - .send() - .await - .map_err(AppError::Network)?; - - let status = res.status(); - tracing::info!(%status, %url, "Received Calendar API response"); - - if !status.is_success() { - let error_body = res.text().await.unwrap_or_default(); - tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); - return Err(AppError::Internal(format!( - "Failed to update event: {} - {}", - status, error_body - ))); - } - - res.json() - .await - .map_err(|e| AppError::Internal(e.to_string())) - } - - #[allow(dead_code)] - pub async fn delete_event(&self, id: i32) -> AppResult<()> { - let token = self.get_token().await?; - let url = format!("{}/service/v1/events/{}", self.base_url, id); - - tracing::info!(method = "DELETE", %url, "Sending Calendar API request"); - - let res = self - .client - .delete(&url) - .bearer_auth(token) - .send() - .await - .map_err(AppError::Network)?; - - let status = res.status(); - tracing::info!(%status, %url, "Received Calendar API response"); - - if !status.is_success() { - let error_body = res.text().await.unwrap_or_default(); - tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); - return Err(AppError::Internal(format!( - "Failed to delete event: {} - {}", - status, error_body - ))); - } - - Ok(()) - } -} diff --git a/src/domain/mod.rs b/src/domain/mod.rs deleted file mode 100644 index 4a21816..0000000 --- a/src/domain/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod agent; -pub mod auth; -pub mod calendar; -pub mod notifications; -pub mod tasks; diff --git a/src/domain/notifications/mod.rs b/src/domain/notifications/mod.rs deleted file mode 100644 index ec19098..0000000 --- a/src/domain/notifications/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod push; diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs deleted file mode 100644 index 478fe2a..0000000 --- a/src/domain/notifications/push.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::error::AppResult; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use web_push::*; - -pub struct PushSender { - pub private_key: String, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct PushSubscription { - pub endpoint: String, - pub p256dh: String, - pub auth: String, -} - -fn ensure_pem(input: &str) -> String { - let input = input.trim(); - if input.contains("-----BEGIN") { - return input.to_string(); - } - - if input.starts_with("MHc") { - format!( - "-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----", - input - ) - } else { - format!( - "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----", - input - ) - } -} - -impl PushSender { - pub fn new(private_key_pem: &str) -> AppResult { - let pem = ensure_pem(private_key_pem); - // Validate key immediately to catch config errors early - let _ = - VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&pem)).map_err(|e| { - crate::error::AppError::Internal(format!("Invalid VAPID private key: {}", e)) - })?; - - Ok(Self { private_key: pem }) - } - - pub fn get_public_key(&self) -> AppResult> { - let builder = - VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - Ok(builder.get_public_key()) - } - - pub async fn send_notification( - &self, - subscription: &PushSubscription, - title: &str, - body: &str, - task_id: Option, - run_id: Option, - ) -> AppResult<()> { - let subscription_info = SubscriptionInfo::new( - subscription.endpoint.clone(), - subscription.p256dh.clone(), - subscription.auth.clone(), - ); - - let builder = - VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - let mut builder = builder.add_sub_info(&subscription_info); - builder.add_claim("sub", "mailto:pavel@flegr.me"); - - let vapid_signature = builder - .build() - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - let mut builder = WebPushMessageBuilder::new(&subscription_info); - - builder.set_vapid_signature(vapid_signature); - - let payload = serde_json::to_vec(&serde_json::json!({ - "title": title, - "body": body, - "task_id": task_id, - "run_id": run_id, - })) - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - builder.set_payload(ContentEncoding::Aes128Gcm, &payload); - - let message = builder - .build() - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - let client = IsahcWebPushClient::new() - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - client.send(message).await.map_err(|e| { - tracing::error!("Failed to send push notification: {}", e); - crate::error::AppError::Internal(e.to_string()) - })?; - - tracing::info!( - "Push notification sent successfully to {}", - subscription.endpoint - ); - - Ok(()) - } -} diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs deleted file mode 100644 index bd518ef..0000000 --- a/src/domain/tasks.rs +++ /dev/null @@ -1,214 +0,0 @@ -use chrono::Utc; -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::config::Config; -use crate::domain::agent::Agent; -use crate::entities::task::Entity as Task; -use crate::entities::task_run::{self, Entity as TaskRun}; -use crate::entities::{push_subscription, task_subscription}; -use crate::scheduler::Scheduler; - -#[derive(Deserialize)] -pub struct CreateTaskRequest { - pub goal: String, - pub cron: Option, -} - -#[derive(Deserialize)] -pub struct UpdateTaskRequest { - pub goal: String, - pub cron: Option, -} - -#[derive(Serialize, Clone, Debug)] -pub struct TaskResponse { - pub id: Uuid, - pub goal: String, - pub cron: Option, - pub created_at: chrono::DateTime, - pub runs: Vec, -} - -#[derive(Serialize, Clone, Debug)] -pub struct TaskRunResponse { - pub id: Uuid, - pub status: String, - pub logs: String, - pub answer: Option, - pub created_at: chrono::DateTime, -} - -#[derive(Serialize, Clone, Debug)] -pub struct RecentRunResponse { - pub id: Uuid, - pub task_id: Uuid, - pub goal: String, - pub status: String, - pub created_at: chrono::DateTime, -} - -use crate::error::AppResult; - -pub async fn execute_agent_run( - db: &DatabaseConnection, - _scheduler: &Arc, - config: &Arc, - calendar_client: Arc, - task_id: Uuid, - goal: String, -) -> AppResult { - let run_id = Uuid::new_v4(); - tracing::info!(%task_id, %run_id, "Starting background agent execution run"); - - let new_run = task_run::ActiveModel { - id: Set(run_id), - task_id: Set(task_id), - status: Set("running".to_string()), - logs: Set(String::new()), - answer: Set(None), - created_at: Set(Utc::now().into()), - }; - - new_run - .insert(db) - .await - .map_err(crate::error::AppError::Database)?; - - let _ = _scheduler - .tx - .send(crate::server::notifications::WsEvent::RunStarted( - RecentRunResponse { - id: run_id, - task_id, - goal: goal.clone(), - status: "running".to_string(), - created_at: Utc::now().into(), - }, - )); - - let mut agent = Agent::new( - db.clone(), - config.zen_api_key.clone(), - config.tavily_api_key.clone(), - calendar_client.clone(), - None, - goal.clone(), - )?; - - let db_bg = db.clone(); - let config_bg = config.clone(); - let scheduler_bg = _scheduler.clone(); - let task_id_bg = task_id; - - tokio::spawn(async move { - let (logs, answer, status) = match agent.run(&config_bg).await { - Ok((logs, answer)) => { - tracing::info!(task_id = %task_id_bg, run_id = %run_id, "Agent execution completed successfully"); - (logs, answer, "completed".to_string()) - } - Err(e) => { - tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Agent execution failed"); - ( - format!("Execution failed: {}", e), - None, - "failed".to_string(), - ) - } - }; - - let run_update = task_run::ActiveModel { - id: Set(run_id), - logs: Set(logs), - answer: Set(answer), - status: Set(status.clone()), - ..Default::default() - }; - - if let Err(e) = run_update.update(&db_bg).await { - tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Failed to update run record"); - } - - if let Ok(task_response) = get_task_inner(task_id_bg, &db_bg).await { - let _ = scheduler_bg - .tx - .send(crate::server::notifications::WsEvent::RunFinished( - task_response.clone(), - )); - - // Send Push Notifications to subscribers - if let Ok(subscriptions) = task_subscription::Entity::find() - .filter(task_subscription::Column::TaskId.eq(task_id_bg)) - .all(&db_bg) - .await - { - for sub in subscriptions { - if let Ok(push_subs) = push_subscription::Entity::find() - .filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone())) - .all(&db_bg) - .await - { - for push_sub in push_subs { - let sender = scheduler_bg.push_sender.clone(); - let goal = task_response.goal.clone(); - let status_bg = status.clone(); - let sub_data = crate::domain::notifications::push::PushSubscription { - endpoint: push_sub.endpoint, - p256dh: push_sub.p256dh, - auth: push_sub.auth, - }; - - tokio::spawn(async move { - let _ = sender - .send_notification( - &sub_data, - &format!("Task Completed: {}", status_bg), - &goal, - Some(task_id_bg), - Some(run_id), - ) - .await; - }); - } - } - } - } - } - }); - - get_task_inner(task_id, db).await -} - -pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult { - let results = Task::find_by_id(id) - .find_with_related(TaskRun) - .all(db) - .await - .map_err(crate::error::AppError::Database)?; - - let (t, mut runs) = results - .into_iter() - .next() - .ok_or_else(|| crate::error::AppError::NotFound("Task not found".into()))?; - - runs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - Ok(TaskResponse { - id: t.id, - goal: t.goal, - cron: t.cron, - created_at: t.created_at, - runs: runs - .into_iter() - .map(|r| TaskRunResponse { - id: r.id, - status: r.status, - logs: r.logs, - answer: r.answer, - created_at: r.created_at, - }) - .collect(), - }) -} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 304ddb0..7ff42bf 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,4 +1,2 @@ -pub mod push_subscription; pub mod task; pub mod task_run; -pub mod task_subscription; diff --git a/src/entities/push_subscription.rs b/src/entities/push_subscription.rs deleted file mode 100644 index c306114..0000000 --- a/src/entities/push_subscription.rs +++ /dev/null @@ -1,19 +0,0 @@ -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "push_subscriptions")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub user_sub: String, - pub endpoint: String, - pub p256dh: String, - pub auth: String, - pub created_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/task_subscription.rs b/src/entities/task_subscription.rs deleted file mode 100644 index b7011ee..0000000 --- a/src/entities/task_subscription.rs +++ /dev/null @@ -1,32 +0,0 @@ -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "task_subscriptions")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub user_sub: String, - pub task_id: Uuid, - pub created_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::task::Entity", - from = "Column::TaskId", - to = "super::task::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Task, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Task.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 81194ce..0000000 --- a/src/error.rs +++ /dev/null @@ -1,57 +0,0 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde_json::json; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum AppError { - #[error("Database error: {0}")] - Database(#[from] sea_orm::DbErr), - - #[error("Configuration error: {0}")] - Config(String), - - #[error("Not found: {0}")] - NotFound(String), - - #[error("Unauthorized: {0}")] - Unauthorized(String), - - #[error("Internal server error: {0}")] - Internal(String), - - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), - - #[error("Invalid request: {0}")] - InvalidRequest(String), -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let (status, error_message) = match &self { - AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()), - AppError::NotFound(err) => (StatusCode::NOT_FOUND, err.clone()), - AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err.clone()), - AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()), - AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()), - AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err.clone()), - }; - - if status.is_server_error() || status.is_client_error() { - tracing::error!(%status, error = %self, "AppError converted to response"); - } - - let body = Json(json!({ - "error": error_message, - })); - - (status, body).into_response() - } -} - -pub type AppResult = Result; diff --git a/src/main.rs b/src/main.rs index 59849ad..14ad58e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,16 @@ -mod config; -mod domain; +mod agent; +mod api; +mod auth; mod entities; -mod error; mod scheduler; mod server; -#[cfg(test)] -mod tests; - -use tracing::info; +mod tools; #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "bot=info,axum=info".into()), - ) - .init(); + let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - info!("Starting Antigravity Agent..."); - - let config = config::Config::from_env()?; - - server::start(config).await?; + server::start(&db_url).await?; Ok(()) } diff --git a/src/scheduler.rs b/src/scheduler.rs index c98debd..d20f257 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,10 +1,8 @@ -use crate::domain::agent::Agent; +use crate::agent::Agent; use crate::entities::task::Entity as Task; use crate::entities::task_run; -use crate::error::{AppError, AppResult}; use dashmap::DashMap; use sea_orm::{DatabaseConnection, EntityTrait, Set}; -use std::sync::Arc; use tokio_cron_scheduler::{Job, JobScheduler}; use uuid::Uuid; @@ -12,107 +10,78 @@ pub struct Scheduler { scheduler: JobScheduler, db: DatabaseConnection, tasks_to_jobs: DashMap, - config: Arc, - pub calendar_client: Arc, - pub tx: tokio::sync::broadcast::Sender, - pub push_sender: Arc, + zen_api_key: Option, + tavily_api_key: Option, } impl Scheduler { pub async fn new( db: DatabaseConnection, - config: Arc, - calendar_client: Arc, - tx: tokio::sync::broadcast::Sender, - push_sender: Arc, - ) -> AppResult { - let scheduler = JobScheduler::new() - .await - .map_err(|e| AppError::Internal(format!("Failed to create scheduler: {}", e)))?; - scheduler - .start() - .await - .map_err(|e| AppError::Internal(format!("Failed to start scheduler: {}", e)))?; + zen_api_key: Option, + tavily_api_key: Option, + ) -> Result> { + let scheduler = JobScheduler::new().await?; + scheduler.start().await?; Ok(Self { scheduler, db, tasks_to_jobs: DashMap::new(), - config, - calendar_client, - tx, - push_sender, + zen_api_key, + tavily_api_key, }) } - pub async fn add_task_job(&self, task_id: Uuid, cron_expr: &str) -> AppResult<()> { + pub async fn add_task_job( + &self, + task_id: Uuid, + cron_expr: &str, + ) -> Result<(), Box> { // Remove existing job if any if let Some((_, old_job_id)) = self.tasks_to_jobs.remove(&task_id) { let _ = self.scheduler.remove(&old_job_id).await; } let db = self.db.clone(); - let config = self.config.clone(); - let tx = self.tx.clone(); - let push_sender = self.push_sender.clone(); + let zen_key = self.zen_api_key.clone(); + let tavily_key = self.tavily_api_key.clone(); - let calendar_client = self.calendar_client.clone(); let job = Job::new_async(cron_expr, move |_uuid, _l| { let db = db.clone(); - let config = config.clone(); - let tx = tx.clone(); - let push_sender = push_sender.clone(); - let calendar_client = calendar_client.clone(); + let zen_key = zen_key.clone(); + let tavily_key = tavily_key.clone(); Box::pin(async move { - if let Err(e) = - Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await - { - tracing::error!("Error in scheduled task {}: {}", task_id, e); + if let Err(e) = Self::run_task(db, zen_key, tavily_key, task_id).await { + eprintln!("Error in scheduled task {}: {}", task_id, e); } }) - }) - .map_err(|e| AppError::Internal(format!("Failed to create job: {}", e)))?; + })?; - let job_id = self - .scheduler - .add(job) - .await - .map_err(|e| AppError::Internal(format!("Failed to add job: {}", e)))?; + let job_id = self.scheduler.add(job).await?; self.tasks_to_jobs.insert(task_id, job_id); - tracing::info!(%task_id, %cron_expr, "Added task to scheduler"); - Ok(()) } - pub async fn remove_task_job(&self, task_id: Uuid) -> AppResult<()> { + pub async fn remove_task_job(&self, task_id: Uuid) -> Result<(), Box> { if let Some((_, job_id)) = self.tasks_to_jobs.remove(&task_id) { - self.scheduler - .remove(&job_id) - .await - .map_err(|e| AppError::Internal(format!("Failed to remove job: {}", e)))?; - tracing::info!(%task_id, "Removed task from scheduler"); + self.scheduler.remove(&job_id).await?; } Ok(()) } async fn run_task( db: DatabaseConnection, - config: Arc, - calendar_client: Arc, - tx: tokio::sync::broadcast::Sender, - push_sender: Arc, + zen_key: Option, + tavily_key: Option, task_id: Uuid, - ) -> AppResult<()> { + ) -> Result<(), Box> { let task = Task::find_by_id(task_id) .one(&db) - .await - .map_err(AppError::Database)? - .ok_or_else(|| AppError::NotFound("Task not found".into()))?; + .await? + .ok_or("Task not found")?; // Create a new run entry let run_id = Uuid::new_v4(); - tracing::info!(task_id = %task_id, run_id = %run_id, "Starting scheduled task execution"); - let run = task_run::ActiveModel { id: Set(run_id), task_id: Set(task_id), @@ -123,104 +92,25 @@ impl Scheduler { }; use sea_orm::ActiveModelTrait; - run.insert(&db).await.map_err(AppError::Database)?; - - let _ = tx.send(crate::server::notifications::WsEvent::RunStarted( - crate::domain::tasks::RecentRunResponse { - id: run_id, - task_id, - goal: task.goal.clone(), - status: "running".to_string(), - created_at: chrono::Utc::now().into(), - }, - )); + run.insert(&db).await?; // Start agent in background - let mut agent = Agent::new( - db.clone(), - config.zen_api_key.clone(), - config.tavily_api_key.clone(), - calendar_client.clone(), - None, - task.goal.clone(), - )?; + let mut agent = Agent::new(zen_key, tavily_key, task.goal.clone())?; tokio::spawn(async move { - let (logs, answer, status) = match agent.run(&config).await { - Ok((logs, answer)) => { - tracing::info!(task_id = %task_id, run_id = %run_id, "Scheduled task execution completed successfully"); - (logs, answer, "completed".to_string()) - } - Err(e) => { - tracing::error!(task_id = %task_id, run_id = %run_id, error = %e, "Scheduled task execution failed"); - ( - format!("Scheduled run failed: {}", e), - None, - "failed".to_string(), - ) - } + let (logs, answer) = match agent.run().await { + Ok(res) => res, + Err(e) => (format!("Scheduled run failed: {}", e), None), }; let run_complete = task_run::ActiveModel { id: Set(run_id), - status: Set(status.clone()), + status: Set("completed".to_string()), logs: Set(logs), answer: Set(answer), ..Default::default() }; - if let Err(e) = run_complete.update(&db).await { - tracing::error!( - "Failed to update scheduled run status for task {}: {}", - task_id, - e - ); - } - - if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await { - let _ = tx.send(crate::server::notifications::WsEvent::RunFinished( - task_response.clone(), - )); - - // Push notifications for scheduled runs - use crate::entities::{push_subscription, task_subscription}; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - if let Ok(subscriptions) = task_subscription::Entity::find() - .filter(task_subscription::Column::TaskId.eq(task_id)) - .all(&db) - .await - { - for sub in subscriptions { - if let Ok(push_subs) = push_subscription::Entity::find() - .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) - .all(&db) - .await - { - for push_sub in push_subs { - let sender = push_sender.clone(); - let sub_data = - crate::domain::notifications::push::PushSubscription { - endpoint: push_sub.endpoint, - p256dh: push_sub.p256dh, - auth: push_sub.auth, - }; - let goal = task_response.goal.clone(); - let status = status.clone(); - tokio::spawn(async move { - let _ = sender - .send_notification( - &sub_data, - &format!("Scheduled Task Completed: {}", status), - &goal, - Some(task_id), - Some(run_id), - ) - .await; - }); - } - } - } - } - } + let _ = run_complete.update(&db).await; }); Ok(()) diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..e7d53b5 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,453 @@ +use axum::{ + Json, RequestPartsExt, Router, + extract::{FromRef, FromRequestParts, Path, Query, State}, + http::{StatusCode, request::Parts}, + routing::{get, post}, +}; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use uuid::Uuid; + +use crate::agent::Agent; +use crate::entities::task::{self, Entity as Task}; +use crate::entities::task_run::{self, Entity as TaskRun}; +use crate::scheduler::Scheduler; +use migration::{Migrator, MigratorTrait}; + +#[derive(Clone)] +pub struct AppState { + pub db: DatabaseConnection, + pub scheduler: Arc, + pub zen_api_key: Option, + pub tavily_api_key: Option, + pub verifier: Arc, + pub authenticator: Arc, +} + +#[derive(Deserialize)] +pub struct CreateTaskRequest { + pub goal: String, + pub cron: Option, +} + +#[derive(Deserialize)] +pub struct UpdateTaskRequest { + pub goal: String, + pub cron: Option, +} + +#[derive(Serialize)] +pub struct TaskResponse { + pub id: Uuid, + pub goal: String, + pub cron: Option, + pub created_at: chrono::DateTime, + pub runs: Vec, +} + +#[derive(Serialize)] +pub struct TaskRunResponse { + pub id: Uuid, + pub status: String, + pub logs: String, + pub answer: Option, + pub created_at: chrono::DateTime, +} + +#[derive(Serialize)] +pub struct RecentRunResponse { + pub id: Uuid, + pub task_id: Uuid, + pub goal: String, + pub status: String, + pub created_at: chrono::DateTime, +} + +pub async fn start(db_url: &str) -> Result<(), Box> { + let db = Database::connect(db_url).await?; + Migrator::up(&db, None).await?; + + let zen_api_key = std::env::var("ZEN_API_KEY").ok(); + let tavily_api_key = std::env::var("TAVILY_API_KEY").ok(); + + let scheduler = + Arc::new(Scheduler::new(db.clone(), zen_api_key.clone(), tavily_api_key.clone()).await?); + + // Load existing scheduled tasks + let existing_tasks = Task::find().all(&db).await?; + for task in existing_tasks { + if let Some(cron) = task.cron { + let _ = scheduler.add_task_job(task.id, &cron).await; + } + } + + let authentik_issuer = + std::env::var("AUTHENTIK_ISSUER").map_err(|_| "AUTHENTIK_ISSUER not set")?; + let authentik_client_id = + std::env::var("AUTHENTIK_CLIENT_ID").map_err(|_| "AUTHENTIK_CLIENT_ID not set")?; + let authentik_client_secret = + std::env::var("AUTHENTIK_CLIENT_SECRET").map_err(|_| "AUTHENTIK_CLIENT_SECRET not set")?; + + let verifier = Arc::new(crate::auth::JwksVerifier::new(authentik_issuer.clone()).await?); + let authenticator = Arc::new( + crate::auth::Authenticator::new( + authentik_issuer, + authentik_client_id, + authentik_client_secret, + ) + .await?, + ); + + let state = Arc::new(AppState { + db, + scheduler, + zen_api_key, + tavily_api_key, + verifier, + authenticator, + }); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/api/tasks", post(create_task).get(list_tasks)) + .route("/api/tasks/:id", get(get_task).put(update_task)) + .route("/api/tasks/:id/runs", post(rerun_task)) + .route("/api/runs/recent", get(get_recent_runs)) + .route("/api/auth/callback", get(auth_callback)) + .route("/api/auth/refresh", post(auth_refresh)) + .layer(cors) + .with_state(state); + + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); + let addr = format!("0.0.0.0:{}", port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + println!("Server running on http://localhost:{}", port); + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn list_tasks( + _user: AuthenticatedUser, + State(state): State>, +) -> Result>, (StatusCode, String)> { + let tasks = Task::find() + .find_with_related(TaskRun) + .order_by_desc(task::Column::CreatedAt) + .all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let response = tasks + .into_iter() + .map(|(t, mut runs)| { + runs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + TaskResponse { + id: t.id, + goal: t.goal, + cron: t.cron, + created_at: t.created_at, + runs: runs + .into_iter() + .map(|r| TaskRunResponse { + id: r.id, + status: r.status, + logs: r.logs, + answer: r.answer, + created_at: r.created_at, + }) + .collect(), + } + }) + .collect(); + + Ok(Json(response)) +} + +async fn create_task( + _user: AuthenticatedUser, + State(state): State>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let task_id = Uuid::new_v4(); + + // Initial task save + let new_task = task::ActiveModel { + id: Set(task_id), + goal: Set(payload.goal.clone()), + cron: Set(payload.cron.clone()), + created_at: Set(Utc::now().into()), + }; + + new_task + .insert(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(cron) = &payload.cron { + let _ = state.scheduler.add_task_job(task_id, cron).await; + } else { + let _ = state.scheduler.remove_task_job(task_id).await; + } + + get_task_inner(task_id, &state).await.map(Json) +} + +async fn rerun_task( + _user: AuthenticatedUser, + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let task = Task::find_by_id(id) + .one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?; + + execute_agent_run(state, task.id, task.goal).await +} + +async fn execute_agent_run( + state: Arc, + task_id: Uuid, + goal: String, +) -> Result, (StatusCode, String)> { + let run_id = Uuid::new_v4(); + + // Initial run save + let new_run = task_run::ActiveModel { + id: Set(run_id), + task_id: Set(task_id), + status: Set("running".to_string()), + logs: Set(String::new()), + answer: Set(None), + created_at: Set(Utc::now().into()), + }; + + new_run + .insert(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut agent = Agent::new( + state.zen_api_key.clone(), + state.tavily_api_key.clone(), + goal.clone(), + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (logs, answer) = match agent.run().await { + Ok((logs, answer)) => (logs, answer), + Err(e) => (format!("Execution failed: {}", e), None), + }; + + // Update with final logs and status + let mut run: task_run::ActiveModel = TaskRun::find_by_id(run_id) + .one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::NOT_FOUND, + "Run not found after insert".to_string(), + ))? + .into(); + + run.logs = Set(logs.clone()); + run.answer = Set(answer.clone()); + run.status = Set("completed".to_string()); + + run.update(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + get_task_inner(task_id, &state).await.map(Json) +} + +async fn update_task( + _user: AuthenticatedUser, + State(state): State>, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let mut task: task::ActiveModel = Task::find_by_id(id) + .one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))? + .into(); + + task.goal = Set(payload.goal.clone()); + task.cron = Set(payload.cron.clone()); + + task.update(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(cron) = &payload.cron { + let _ = state.scheduler.add_task_job(id, cron).await; + } else { + let _ = state.scheduler.remove_task_job(id).await; + } + + get_task_inner(id, &state).await.map(Json) +} + +#[allow(dead_code)] +pub struct AuthenticatedUser(pub crate::auth::Claims); + +#[axum::async_trait] +impl FromRequestParts for AuthenticatedUser +where + Arc: axum::extract::FromRef, + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = Arc::::from_ref(state); + + let TypedHeader(Authorization(bearer)) = parts + .extract::>>() + .await + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + "Missing or invalid Authorization header".to_string(), + ) + })?; + + let claims = app_state + .verifier + .verify(bearer.token()) + .await + .map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + format!("Token verification failed: {}", e), + ) + })?; + + Ok(AuthenticatedUser(claims)) + } +} + +#[derive(Deserialize)] +pub struct AuthCallbackQuery { + pub code: String, + pub redirect_uri: String, +} + +#[derive(Deserialize)] +struct RefreshRequest { + refresh_token: String, +} + +async fn auth_refresh( + State(state): State>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + state + .authenticator + .refresh_token(payload.refresh_token) + .await + .map(Json) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string())) +} + +async fn auth_callback( + State(state): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + state + .authenticator + .exchange_code(query.code, query.redirect_uri) + .await + .map(Json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Token exchange failed: {}", e), + ) + }) +} + +async fn get_task( + _user: AuthenticatedUser, + Path(id): Path, + State(state): State>, +) -> Result, (StatusCode, String)> { + get_task_inner(id, &state).await.map(Json) +} + +async fn get_task_inner(id: Uuid, state: &AppState) -> Result { + let results = Task::find_by_id(id) + .find_with_related(TaskRun) + .all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (t, mut runs) = results + .into_iter() + .next() + .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?; + + runs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + Ok(TaskResponse { + id: t.id, + goal: t.goal, + cron: t.cron, + created_at: t.created_at, + runs: runs + .into_iter() + .map(|r| TaskRunResponse { + id: r.id, + status: r.status, + logs: r.logs, + answer: r.answer, + created_at: r.created_at, + }) + .collect(), + }) +} + +async fn get_recent_runs( + _user: AuthenticatedUser, + State(state): State>, +) -> Result>, (StatusCode, String)> { + let results = TaskRun::find() + .find_also_related(Task) + .order_by_desc(task_run::Column::CreatedAt) + .limit(50) + .all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let response = results + .into_iter() + .filter_map(|(run, task_opt)| { + task_opt.map(|task| RecentRunResponse { + id: run.id, + task_id: run.task_id, + goal: task.goal, + status: run.status, + created_at: run.created_at, + }) + }) + .collect(); + + Ok(Json(response)) +} diff --git a/src/server/auth.rs b/src/server/auth.rs deleted file mode 100644 index 7ff1086..0000000 --- a/src/server/auth.rs +++ /dev/null @@ -1,169 +0,0 @@ -use axum::{ - Json, RequestPartsExt, - extract::{FromRef, FromRequestParts, Query, State}, - http::request::Parts, - response::IntoResponse, -}; -use axum_extra::{ - TypedHeader, - extract::cookie::{Cookie, CookieJar, SameSite}, - headers::{Authorization, authorization::Bearer}, -}; -use std::sync::Arc; - -use super::AppState; -use crate::error::{AppError, AppResult}; - -pub struct AuthenticatedUser(pub crate::domain::auth::Claims); - -#[axum::async_trait] -impl FromRequestParts for AuthenticatedUser -where - Arc: axum::extract::FromRef, - S: Send + Sync, -{ - type Rejection = AppError; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let app_state = Arc::::from_ref(state); - - let token = if let Ok(TypedHeader(Authorization(bearer))) = - parts.extract::>>().await - { - Some(bearer.token().to_string()) - } else { - let jar = parts - .extract::() - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - jar.get("access_token") - .map(|cookie| cookie.value().to_string()) - }; - - let token = token - .ok_or_else(|| AppError::Unauthorized("Missing or invalid access token".into()))?; - - let claims = app_state - .verifier - .verify(&token) - .await - .map_err(|e| AppError::Unauthorized(format!("Token verification failed: {}", e)))?; - - Ok(AuthenticatedUser(claims)) - } -} - -#[derive(serde::Deserialize)] -pub struct AuthCallbackQuery { - pub code: String, - pub redirect_uri: String, -} - -#[derive(serde::Deserialize)] -pub struct RefreshRequest { - pub refresh_token: Option, -} - -pub async fn auth_refresh( - State(state): State>, - jar: CookieJar, - Json(payload): Json, -) -> AppResult { - let refresh_token = payload - .refresh_token - .filter(|token| !token.is_empty()) - .or_else(|| { - jar.get("refresh_token") - .map(|cookie| cookie.value().to_string()) - }) - .ok_or_else(|| AppError::Unauthorized("Missing refresh token".into()))?; - - let data = state - .authenticator - .refresh_token(refresh_token) - .await - .map_err(|e| AppError::Unauthorized(e.to_string()))?; - - let jar = update_auth_cookies(jar, &data, &state.config); - Ok((jar, Json(data))) -} - -pub async fn auth_callback( - State(state): State>, - jar: CookieJar, - Query(query): Query, -) -> AppResult { - let data = state - .authenticator - .exchange_code(query.code, query.redirect_uri) - .await - .map_err(|e| AppError::Internal(format!("Token exchange failed: {}", e)))?; - - let jar = update_auth_cookies(jar, &data, &state.config); - Ok((jar, Json(data))) -} - -pub async fn auth_logout(State(state): State>, jar: CookieJar) -> impl IntoResponse { - let jar = clear_auth_cookies(jar, &state.config); - (jar, axum::http::StatusCode::NO_CONTENT) -} - -pub async fn auth_session(user: AuthenticatedUser) -> Json { - Json(serde_json::json!({ - "authenticated": true, - "user": user.0 - })) -} - -fn secure(config: &crate::config::Config) -> bool { - config.cookie_secure -} - -pub fn update_auth_cookies( - jar: CookieJar, - data: &serde_json::Value, - config: &crate::config::Config, -) -> CookieJar { - let access_token = data.get("access_token"); - let refresh_token = data.get("refresh_token"); - - let mut jar = jar; - - if let Some(token) = access_token.and_then(|t| t.as_str()) { - let cookie = Cookie::build(("access_token", token.to_owned())) - .path("/") - .http_only(true) - .same_site(SameSite::Lax) - .secure(secure(config)) - .build(); - jar = jar.add(cookie); - } - - if let Some(token) = refresh_token.and_then(|t| t.as_str()) { - let cookie = Cookie::build(("refresh_token", token.to_owned())) - .path("/") - .http_only(true) - .same_site(SameSite::Lax) - .secure(secure(config)) - .build(); - jar = jar.add(cookie); - } - - jar -} - -pub fn clear_auth_cookies(jar: CookieJar, config: &crate::config::Config) -> CookieJar { - let mut jar = jar; - for name in ["access_token", "refresh_token"] { - let cookie = Cookie::build((name, "")) - .path("/") - .http_only(true) - .same_site(SameSite::Lax) - .secure(secure(config)) - .max_age(cookie::time::Duration::seconds(0)) - .build(); - jar = jar.add(cookie); - } - - jar -} diff --git a/src/server/chat.rs b/src/server/chat.rs deleted file mode 100644 index 6e24d4b..0000000 --- a/src/server/chat.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::domain::agent::api::Message; -use crate::error::AppError; -use crate::server::AppState; -use axum::{Json, extract::State}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Debug, Deserialize)] -pub struct ChatPayload { - pub messages: Vec, -} - -#[derive(Debug, Serialize)] -pub struct ChatResult { - pub message: Message, -} - -pub async fn chat_handler( - State(state): State>, - user: crate::server::auth::AuthenticatedUser, - Json(payload): Json, -) -> Result, AppError> { - let msg_count = payload.messages.len(); - tracing::info!("Received chat request with {} messages", msg_count); - - let mut messages = payload.messages; - - // Inject current date awareness if not already present or as a fresh system message - let now = chrono::Local::now(); - let date_str = now.format("%A, %B %e, %Y at %l:%M %P").to_string(); - messages.insert( - 0, - Message { - role: "system".to_string(), - content: Some(format!( - "The current date and time is {}. Today is {}. You are in interactive chat mode.", - date_str, - now.format("%Y-%m-%d") - )), - tool_calls: None, - tool_call_id: None, - }, - ); - - let mut agent = crate::domain::agent::Agent::with_messages( - state.db.clone(), - state.config.zen_api_key.clone(), - state.config.tavily_api_key.clone(), - state.calendar_client.clone(), - Some(user.0.sub), - messages, - )?; - - tracing::info!("Starting interactive agent turn"); - let assistant_message = - match tokio::time::timeout(std::time::Duration::from_secs(180), agent.execute_turn()).await - { - Ok(res) => res?, - Err(_) => { - tracing::error!("Interactive chat agent turn timed out after 180s"); - return Err(AppError::Internal("Agent turn timed out".into())); - } - }; - - Ok(Json(ChatResult { - message: assistant_message, - })) -} diff --git a/src/server/mod.rs b/src/server/mod.rs deleted file mode 100644 index 5160b4e..0000000 --- a/src/server/mod.rs +++ /dev/null @@ -1,219 +0,0 @@ -pub mod auth; -pub mod chat; -pub mod notifications; -pub mod tasks; - -use axum::{ - Router, - http::HeaderValue, - routing::{get, post}, -}; -use migration::{Migrator, MigratorTrait}; -use sea_orm::{Database, DatabaseConnection, EntityTrait}; -use std::sync::Arc; -use tower_http::cors::{AllowOrigin, CorsLayer}; - -use crate::scheduler::Scheduler; - -use crate::error::AppResult; - -#[derive(Clone)] -pub struct AppState { - pub db: DatabaseConnection, - pub scheduler: Arc, - pub config: Arc, - pub verifier: Arc, - pub authenticator: Arc, - pub calendar_client: Arc, - pub tx: tokio::sync::broadcast::Sender, -} - -pub async fn start(config: crate::config::Config) -> AppResult<()> { - let db = setup_database(&config.database_url).await?; - - let config = Arc::new(config); - - let (tx, _) = tokio::sync::broadcast::channel(100); - - let push_sender = Arc::new(crate::domain::notifications::push::PushSender::new( - &config.vapid_private_key.clone(), - )?); - - let (verifier, authenticator) = setup_auth(&config).await?; - let calendar_client = Arc::new(crate::domain::calendar::CalendarClient::new( - config.calendar_api_url.clone(), - authenticator.clone(), - )); - - let scheduler = Arc::new( - Scheduler::new( - db.clone(), - config.clone(), - calendar_client.clone(), - tx.clone(), - push_sender.clone(), - ) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, - ); - - // Load existing scheduled tasks - use crate::entities::task::Entity as Task; - let existing_tasks = Task::find() - .all(&db) - .await - .map_err(crate::error::AppError::Database)?; - for task in existing_tasks { - if let Some(cron) = task.cron { - let _ = scheduler.add_task_job(task.id, &cron).await; - } - } - - let state = Arc::new(AppState { - db, - scheduler, - config: config.clone(), - verifier, - authenticator, - calendar_client, - tx, - }); - - let app = build_app(state, &config); - - let addr = format!("0.0.0.0:{}", config.port); - let listener = tokio::net::TcpListener::bind(&addr) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - tracing::info!("Server running on http://localhost:{}", config.port); - axum::serve(listener, app) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - - Ok(()) -} - -async fn setup_database(database_url: &str) -> AppResult { - let db = Database::connect(database_url) - .await - .map_err(crate::error::AppError::Database)?; - Migrator::up(&db, None) - .await - .map_err(crate::error::AppError::Database)?; - Ok(db) -} - -async fn setup_auth( - config: &crate::config::Config, -) -> AppResult<( - Arc, - Arc, -)> { - let verifier = Arc::new( - crate::domain::auth::JwksVerifier::new( - config.authentik_issuer.clone(), - config.authentik_client_id.clone(), - ) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, - ); - let authenticator = Arc::new( - crate::domain::auth::Authenticator::new( - config.authentik_issuer.clone(), - config.authentik_client_id.clone(), - config.authentik_client_secret.clone(), - ) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, - ); - - Ok((verifier, authenticator)) -} - -fn build_app(state: Arc, config: &crate::config::Config) -> Router { - let cors = build_cors_layer(config); - - Router::new() - .route("/api/tasks", post(tasks::create_task).get(tasks::list_tasks)) - .route("/api/tasks/:id", get(tasks::get_task).put(tasks::update_task)) - .route("/api/tasks/:id/runs", post(tasks::rerun_task)) - .route("/api/runs/recent", get(tasks::get_recent_runs)) - .route("/api/auth/session", get(auth::auth_session)) - .route("/api/auth/callback", get(auth::auth_callback)) - .route("/api/auth/refresh", post(auth::auth_refresh)) - .route("/api/auth/logout", post(auth::auth_logout)) - .route("/api/chat", post(chat::chat_handler)) - .route("/api/ws", get(notifications::ws_handler)) - .route("/api/notifications/register", post(notifications::push_handlers::register_push)) - .route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key)) - .route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status)) - .route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task)) - .layer(axum::middleware::from_fn(log_error_responses)) - .layer(cors) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - axum::http::header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - axum::http::header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - axum::http::header::REFERRER_POLICY, - HeaderValue::from_static("strict-origin-when-cross-origin"), - )) - .layer(tower_http::limit::RequestBodyLimitLayer::new(1024 * 1024)) // 1MB limit - .with_state(state) -} - -async fn log_error_responses( - req: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let method = req.method().clone(); - let uri = req.uri().clone(); - let res = next.run(req).await; - let status = res.status(); - - if status.is_client_error() || status.is_server_error() { - tracing::error!(%method, %uri, %status, "Response error"); - } - - res -} - -fn build_cors_layer(config: &crate::config::Config) -> CorsLayer { - let allow_origin = if let Some(origins) = &config.cors_allowed_origins { - let values: Vec = origins - .split(',') - .map(|origin| origin.trim()) - .filter(|origin| !origin.is_empty()) - .filter_map(|origin| HeaderValue::from_str(origin).ok()) - .collect(); - - if values.is_empty() { - AllowOrigin::mirror_request() - } else { - AllowOrigin::list(values) - } - } else { - AllowOrigin::mirror_request() - }; - - CorsLayer::new() - .allow_origin(allow_origin) - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - axum::http::Method::PUT, - axum::http::Method::PATCH, - axum::http::Method::DELETE, - axum::http::Method::OPTIONS, - ]) - .allow_headers([ - axum::http::header::CONTENT_TYPE, - axum::http::header::AUTHORIZATION, - axum::http::header::ACCEPT, - ]) - .allow_credentials(true) -} diff --git a/src/server/notifications.rs b/src/server/notifications.rs deleted file mode 100644 index 0786d0f..0000000 --- a/src/server/notifications.rs +++ /dev/null @@ -1,48 +0,0 @@ -pub mod push_handlers; - -use crate::domain::tasks::{RecentRunResponse, TaskResponse}; -use crate::server::AppState; -use axum::{ - extract::{ - State, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::IntoResponse, -}; -use serde::Serialize; -use std::sync::Arc; - -#[derive(Serialize, Clone, Debug)] -#[serde(tag = "type", content = "data")] -pub enum WsEvent { - TaskCreated(TaskResponse), - TaskUpdated(TaskResponse), - RunStarted(RecentRunResponse), - RunFinished(TaskResponse), -} - -pub async fn ws_handler( - ws: WebSocketUpgrade, - State(state): State>, -) -> impl IntoResponse { - ws.on_upgrade(|socket| handle_socket(socket, state)) -} - -async fn handle_socket(mut socket: WebSocket, state: Arc) { - let mut rx = state.tx.subscribe(); - - while let Ok(event) = rx.recv().await { - let msg = match serde_json::to_string(&event) { - Ok(json) => json, - Err(e) => { - tracing::error!("Failed to serialize WsEvent: {}", e); - continue; - } - }; - - if socket.send(Message::Text(msg)).await.is_err() { - // Client disconnected - break; - } - } -} diff --git a/src/server/notifications/push_handlers.rs b/src/server/notifications/push_handlers.rs deleted file mode 100644 index cc38e19..0000000 --- a/src/server/notifications/push_handlers.rs +++ /dev/null @@ -1,133 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use chrono::Utc; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; -use std::sync::Arc; -use uuid::Uuid; - -use crate::entities::{push_subscription, task_subscription}; -use crate::error::{AppError, AppResult}; -use crate::server::AppState; -use crate::server::auth::AuthenticatedUser; -use base64::Engine; - -#[derive(serde::Deserialize)] -pub struct RegisterPushRequest { - pub endpoint: String, - pub p256dh: String, - pub auth: String, -} - -pub async fn register_push( - State(state): State>, - user: AuthenticatedUser, - Json(payload): Json, -) -> AppResult> { - let user_sub = user.0.sub; - tracing::info!( - "Registering push subscription for user: {} with endpoint: {}", - user_sub, - payload.endpoint - ); - - // Check if subscription exists - let existing = push_subscription::Entity::find() - .filter(push_subscription::Column::UserSub.eq(user_sub.clone())) - .filter(push_subscription::Column::Endpoint.eq(payload.endpoint.clone())) - .one(&state.db) - .await - .map_err(AppError::Database)?; - - if existing.is_none() { - let new_sub = push_subscription::ActiveModel { - id: Set(Uuid::new_v4()), - user_sub: Set(user_sub), - endpoint: Set(payload.endpoint), - p256dh: Set(payload.p256dh), - auth: Set(payload.auth), - created_at: Set(Utc::now().into()), - }; - new_sub - .insert(&state.db) - .await - .map_err(AppError::Database)?; - } - - Ok(Json(serde_json::json!({ "status": "registered" }))) -} - -pub async fn subscribe_task( - State(state): State>, - user: AuthenticatedUser, - Path(task_id): Path, -) -> AppResult> { - let user_sub = user.0.sub; - - let existing = task_subscription::Entity::find() - .filter(task_subscription::Column::UserSub.eq(user_sub.clone())) - .filter(task_subscription::Column::TaskId.eq(task_id)) - .one(&state.db) - .await - .map_err(AppError::Database)?; - - if existing.is_none() { - let new_sub = task_subscription::ActiveModel { - id: Set(Uuid::new_v4()), - user_sub: Set(user_sub), - task_id: Set(task_id), - created_at: Set(Utc::now().into()), - }; - new_sub - .insert(&state.db) - .await - .map_err(AppError::Database)?; - } - - Ok(Json(serde_json::json!({ "status": "subscribed" }))) -} - -pub async fn unsubscribe_task( - State(state): State>, - user: AuthenticatedUser, - Path(task_id): Path, -) -> AppResult> { - let user_sub = user.0.sub; - - task_subscription::Entity::delete_many() - .filter(task_subscription::Column::UserSub.eq(user_sub)) - .filter(task_subscription::Column::TaskId.eq(task_id)) - .exec(&state.db) - .await - .map_err(AppError::Database)?; - - Ok(Json(serde_json::json!({ "status": "unsubscribed" }))) -} - -pub async fn get_vapid_key( - State(state): State>, -) -> AppResult> { - let public_key = state.scheduler.push_sender.get_public_key()?; - let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key); - Ok(Json(serde_json::json!({ "publicKey": encoded }))) -} - -pub async fn get_subscription_status( - State(state): State>, - user: AuthenticatedUser, - Path(task_id): Path, -) -> AppResult> { - let user_sub = user.0.sub; - - let existing = task_subscription::Entity::find() - .filter(task_subscription::Column::UserSub.eq(user_sub)) - .filter(task_subscription::Column::TaskId.eq(task_id)) - .one(&state.db) - .await - .map_err(AppError::Database)?; - - Ok(Json( - serde_json::json!({ "isSubscribed": existing.is_some() }), - )) -} diff --git a/src/server/tasks.rs b/src/server/tasks.rs deleted file mode 100644 index fddc842..0000000 --- a/src/server/tasks.rs +++ /dev/null @@ -1,205 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use sea_orm::{EntityTrait, QueryOrder, QuerySelect}; -use std::sync::Arc; -use uuid::Uuid; - -use super::AppState; -use super::auth::AuthenticatedUser; -use crate::domain::tasks::{ - self, CreateTaskRequest, RecentRunResponse, TaskResponse, UpdateTaskRequest, -}; - -use crate::error::AppResult; - -pub async fn list_tasks( - _user: AuthenticatedUser, - State(state): State>, -) -> AppResult>> { - let tasks = crate::entities::task::Entity::find() - .find_with_related(crate::entities::task_run::Entity) - .order_by_desc(crate::entities::task::Column::CreatedAt) - .all(&state.db) - .await?; - - let response = tasks - .into_iter() - .map(|(t, mut runs)| { - runs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - TaskResponse { - id: t.id, - goal: t.goal, - cron: t.cron, - created_at: t.created_at, - runs: runs - .into_iter() - .map(|r| tasks::TaskRunResponse { - id: r.id, - status: r.status, - logs: r.logs, - answer: r.answer, - created_at: r.created_at, - }) - .collect(), - } - }) - .collect(); - - Ok(Json(response)) -} - -pub async fn create_task( - _user: AuthenticatedUser, - State(state): State>, - Json(payload): Json, -) -> AppResult> { - if payload.goal.trim().is_empty() { - return Err(crate::error::AppError::InvalidRequest( - "Goal cannot be empty".into(), - )); - } - - if payload.goal.trim().len() < 5 { - return Err(crate::error::AppError::InvalidRequest( - "Goal is too short (min 5 characters)".into(), - )); - } - - let task_id = Uuid::new_v4(); - tracing::info!(%task_id, goal = %payload.goal, "Creating new task"); - - let new_task = crate::entities::task::ActiveModel { - id: sea_orm::Set(task_id), - goal: sea_orm::Set(payload.goal), - cron: sea_orm::Set(payload.cron.clone()), - created_at: sea_orm::Set(chrono::Utc::now().into()), - }; - - use sea_orm::ActiveModelTrait; - new_task.insert(&state.db).await?; - - if let Some(cron) = &payload.cron { - let _ = state.scheduler.add_task_job(task_id, cron).await; - } - - let task_response = tasks::get_task_inner(task_id, &state.db).await?; - let _ = state - .tx - .send(crate::server::notifications::WsEvent::TaskCreated( - task_response.clone(), - )); - Ok(Json(task_response)) -} - -pub async fn rerun_task( - _user: AuthenticatedUser, - State(state): State>, - Path(id): Path, -) -> AppResult> { - let task = crate::entities::task::Entity::find_by_id(id) - .one(&state.db) - .await? - .ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))?; - - tracing::info!(task_id = %task.id, "Manually triggering task rerun"); - - tasks::execute_agent_run( - &state.db, - &state.scheduler, - &state.config, - state.calendar_client.clone(), - task.id, - task.goal, - ) - .await - .map(Json) - .map_err(|e| crate::error::AppError::Internal(e.to_string())) -} - -pub async fn update_task( - _user: AuthenticatedUser, - State(state): State>, - Path(id): Path, - Json(payload): Json, -) -> AppResult> { - if payload.goal.trim().is_empty() { - return Err(crate::error::AppError::InvalidRequest( - "Goal cannot be empty".into(), - )); - } - - if payload.goal.trim().len() < 5 { - return Err(crate::error::AppError::InvalidRequest( - "Goal is too short (min 5 characters)".into(), - )); - } - - let task: crate::entities::task::ActiveModel = crate::entities::task::Entity::find_by_id(id) - .one(&state.db) - .await? - .ok_or_else(|| crate::error::AppError::NotFound("Task not found".to_string()))? - .into(); - - let mut task = task; - tracing::info!(task_id = %id, goal = %payload.goal, "Updating task"); - - task.goal = sea_orm::Set(payload.goal); - task.cron = sea_orm::Set(payload.cron.clone()); - - use sea_orm::ActiveModelTrait; - task.update(&state.db).await?; - - if let Some(cron) = &payload.cron { - let _ = state.scheduler.add_task_job(id, cron).await; - } else { - let _ = state.scheduler.remove_task_job(id).await; - } - - let task_response = tasks::get_task_inner(id, &state.db).await?; - let _ = state - .tx - .send(crate::server::notifications::WsEvent::TaskUpdated( - task_response.clone(), - )); - Ok(Json(task_response)) -} - -pub async fn get_task( - _user: AuthenticatedUser, - Path(id): Path, - State(state): State>, -) -> AppResult> { - tasks::get_task_inner(id, &state.db) - .await - .map(Json) - .map_err(|e| crate::error::AppError::Internal(e.to_string())) -} - -pub async fn get_recent_runs( - _user: AuthenticatedUser, - State(state): State>, -) -> AppResult>> { - let results = crate::entities::task_run::Entity::find() - .find_also_related(crate::entities::task::Entity) - .order_by_desc(crate::entities::task_run::Column::CreatedAt) - .limit(50) - .all(&state.db) - .await?; - - let response = results - .into_iter() - .filter_map(|(run, task_opt)| { - task_opt.map(|task| RecentRunResponse { - id: run.id, - task_id: run.task_id, - goal: task.goal, - status: run.status, - created_at: run.created_at, - }) - }) - .collect(); - - Ok(Json(response)) -} diff --git a/src/tests.rs b/src/tests.rs deleted file mode 100644 index ca3206e..0000000 --- a/src/tests.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::config::Config; -use crate::error::{AppError, AppResult}; -use axum::http::StatusCode; -use axum::response::IntoResponse; - -#[test] -fn test_app_error_into_response() { - let err = AppError::NotFound("Resource not found".into()); - let response = err.into_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - - let err = AppError::Unauthorized("Invalid token".into()); - let response = err.into_response(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - let err = AppError::Internal("Server glitch".into()); - let response = err.into_response(); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); -} - -#[tokio::test] -async fn test_config_validation() { - // We can't easily clear all env vars in multi-threaded tests, - // but we can test that it fails if a required one is missing (if we can ensure it's missing) - // However, for this environment, it's safer to test the mapping logic if it was more complex. - - // Instead, let's test a helper if we had one, or just verify AppResult works as expected. - let result: AppResult = Err(AppError::Config("Missing DATABASE_URL".into())); - assert!(result.is_err()); - if let Err(AppError::Config(msg)) = result { - assert_eq!(msg, "Missing DATABASE_URL"); - } -} - -#[test] -fn test_error_variants() { - let err = AppError::InvalidRequest("Bad input".into()); - assert!(err.to_string().contains("Bad input")); -} diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..ef1c094 --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,90 @@ +use crate::api::{self, FunctionDefinition, Message, Tool, ToolCall}; +use std::collections::HashMap; + +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".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "result": { + "type": "string", + "description": "The result of the task" + } + }, + "required": ["result"] + }), + }, + }, + ] +} + +pub async fn handle_tool_call( + tool_call: &ToolCall, + tavily_api_key: &Option, +) -> Result<(Message, bool, Option), Box> { + let mut answer = None; + let name = &tool_call.function.name; + + let (content, written) = if name == "google_search" { + let args: HashMap = serde_json::from_str(&tool_call.function.arguments)?; + let query = args.get("query").ok_or("Missing query argument")?; + + println!( + "--- Executing tool: google_search(query: \"{}\") ---", + query + ); + + let search_result = if let Some(key) = tavily_api_key { + match api::perform_search(query, key).await { + Ok(results) => results, + Err(e) => format!("Search error: {}", e), + } + } else { + "Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string() + }; + (search_result, false) + } else if name == "finish" { + let args: HashMap = serde_json::from_str(&tool_call.function.arguments)?; + let result = args.get("result").ok_or("Missing result argument")?; + + println!("--- Finishing task: {}", result); + + answer = Some(result.clone()); + (result.clone(), true) + } else { + (format!("Error: Unknown tool {}", name), false) + }; + + Ok(( + Message { + role: "tool".to_string(), + content: Some(content), + tool_calls: None, + tool_call_id: Some(tool_call.id.clone()), + }, + written, + answer, + )) +}