diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index fdcd8f5..735c2ef 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -11,6 +11,31 @@ jobs: with: node-version: 24 - uses: actions/checkout@v6 + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Cache Cargo target + uses: actions/cache@v4 + with: + path: target/ + key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-target- - run: | cd frontend npm install diff --git a/.gitignore b/.gitignore index 771536c..9bf488d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target node_modules -/frontend/dist \ No newline at end of file +/frontend/dist +.env \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..907bd9b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md + +## Overview + +This is **Antigravity Agent** — an autonomous AI agent platform built with a Rust backend and a vanilla JS frontend. Users create "tasks" (goals), and the system dispatches an LLM-powered agent to accomplish them. Tasks can be run on-demand or on a cron schedule. Each execution produces logs and a final answer, all persisted to PostgreSQL. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend (Vite + Vanilla JS) │ +│ - OAuth login via Authentik │ +│ - Dashboard: recent runs, task list, logs, answers │ +│ - Polls /api/tasks every 3s for live updates │ +└──────────────────────┬──────────────────────────────────┘ + │ /api/* +┌──────────────────────▼──────────────────────────────────┐ +│ Axum HTTP Server (src/server/) │ +│ - CORS, CSP, rate limiting (1 MB body) │ +│ - Cookie-based + Bearer token auth │ +│ - Routes: tasks CRUD, runs, auth (callback/refresh) │ +├─────────────────────────────────────────────────────────┤ +│ Domain Layer (src/domain/) │ +│ ├─ agent/ Agent loop, LLM API client, tool defs │ +│ ├─ auth JwksVerifier, Authenticator (OIDC) │ +│ └─ tasks Task execution, run management │ +├─────────────────────────────────────────────────────────┤ +│ Scheduler (src/scheduler.rs) │ +│ - tokio-cron-scheduler for recurring task execution │ +├─────────────────────────────────────────────────────────┤ +│ Entities (src/entities/) │ +│ - task, task_run (SeaORM models) │ +├─────────────────────────────────────────────────────────┤ +│ PostgreSQL │ +│ - Migrations managed via sea-orm-migration │ +└─────────────────────────────────────────────────────────┘ +``` + +## Project Structure + +``` +bot/ +├── Cargo.toml # Workspace root (members: ".", "migration") +├── src/ +│ ├── main.rs # Entrypoint: tracing init → server::start() +│ ├── config.rs # Config struct loaded from env vars +│ ├── error.rs # AppError enum (thiserror) → Axum responses +│ ├── scheduler.rs # Cron job scheduler (wraps tokio-cron-scheduler) +│ ├── tests.rs # Unit tests for error handling & config +│ ├── domain/ +│ │ ├── agent/ +│ │ │ ├── mod.rs # Agent struct: agentic loop with turn/time limits +│ │ │ ├── api.rs # LLM request/response types, Tavily search client +│ │ │ └── tools.rs # Tool definitions (google_search, finish) & dispatch +│ │ ├── auth.rs # JwksVerifier (RSA/JWKS), Authenticator (code exchange, refresh) +│ │ └── tasks.rs # Task execution logic, response DTOs +│ ├── entities/ +│ │ ├── task.rs # SeaORM entity: tasks table +│ │ └── task_run.rs # SeaORM entity: task_runs table (belongs_to task) +│ └── server/ +│ ├── mod.rs # App bootstrap: DB, scheduler, auth, router, CORS +│ ├── auth.rs # Auth routes & AuthenticatedUser extractor +│ └── tasks.rs # Task CRUD & run endpoints +├── migration/ +│ └── src/ # SeaORM migrations (tasks, answer col, runs table, cron col) +├── frontend/ +│ ├── index.html # SPA shell with glassmorphism dark theme +│ ├── src/ +│ │ ├── main.js # All app logic: auth flow, task/run rendering, polling +│ │ └── style.css # Styles +│ ├── vite.config.js # Dev proxy: /api → localhost:3000 +│ └── package.json # Deps: vite, marked, dompurify +└── .forgejo/workflows/ + └── pipeline.yaml # CI: build frontend + cargo build → deploy via systemd +``` + +## Agent System + +The agent (`src/domain/agent/`) is a turn-based autonomous loop: + +1. A system prompt is injected with the current date and instructions not to ask the user for clarification. +2. The user's goal is sent as the initial message. +3. Each turn calls the **Kimi K2.5** model via the Zen API (`https://opencode.ai/zen/v1/chat/completions`). +4. The model can invoke tools: + - **`google_search`** — web search via the Tavily API. + - **`finish`** — signals completion and provides the final answer. +5. Tool results are appended to the conversation and the loop continues. +6. The loop terminates when `finish` is called, the turn limit is hit (`AGENT_MAX_TURNS`, default 20), or the time limit expires (`AGENT_MAX_DURATION_SECS`, default 120s). + +All turns and tool calls are logged. The final answer (if any) and the full log are persisted to the `task_runs` table. + +## Environment Variables + +| Variable | Required | Default | Description | +|---------------------------|----------|---------|----------------------------------------------| +| `DATABASE_URL` | ✅ | — | PostgreSQL connection string | +| `PORT` | | `3000` | HTTP server port | +| `ZEN_API_KEY` | | — | API key for Zen/Kimi LLM | +| `TAVILY_API_KEY` | | — | API key for Tavily web search | +| `AUTHENTIK_ISSUER` | ✅ | — | OIDC issuer URL (Authentik) | +| `AUTHENTIK_CLIENT_ID` | ✅ | — | OAuth client ID | +| `AUTHENTIK_CLIENT_SECRET` | ✅ | — | OAuth client secret | +| `CORS_ALLOWED_ORIGINS` | | — | Comma-separated allowed origins (or mirror) | +| `COOKIE_SECURE` | | `false` | Set `true` for HTTPS-only cookies | +| `AGENT_MAX_TURNS` | | `20` | Max LLM turns per agent run | +| `AGENT_MAX_DURATION_SECS` | | `120` | Max wall-clock seconds per agent run | + +## API Routes + +All task/run routes require authentication (cookie or Bearer token). + +| Method | Path | Description | +|--------|------------------------|-----------------------------------| +| GET | `/api/tasks` | List all tasks with their runs | +| POST | `/api/tasks` | Create a new task | +| GET | `/api/tasks/:id` | Get a single task with runs | +| PUT | `/api/tasks/:id` | Update task goal/cron schedule | +| POST | `/api/tasks/:id/runs` | Trigger a manual re-run | +| GET | `/api/runs/recent` | Latest 50 runs across all tasks | +| GET | `/api/auth/session` | Check current session | +| GET | `/api/auth/callback` | OAuth code → token exchange | +| POST | `/api/auth/refresh` | Refresh access token | +| POST | `/api/auth/logout` | Clear auth cookies | + +## Database Schema + +**`tasks`** — `id` (UUID PK), `goal` (text), `cron` (text, nullable), `created_at` (timestamptz) + +**`task_runs`** — `id` (UUID PK), `task_id` (FK → tasks, cascade delete), `status` (text), `logs` (text), `answer` (text, nullable), `created_at` (timestamptz) + +Migrations are in `migration/src/` and run automatically on startup via `Migrator::up()`. + +## Development + +```bash +# Backend (from repo root) +cargo run # requires DATABASE_URL + Authentik vars + +# Frontend (from frontend/) +npm install +npm run dev # Vite dev server on :5173, proxies /api to :3000 + +# Tests +cargo test +``` + +## CI / Deployment + +The Forgejo Actions pipeline (`.forgejo/workflows/pipeline.yaml`) triggers on release publish: + +1. Builds the frontend (`npm install && npm run build`). +2. Builds the Rust binary (`cargo build -r`). +3. Uploads the binary as a release asset. +4. Deploys to the host: copies binary + frontend dist, restarts the `bot` systemd user service. diff --git a/Cargo.lock b/Cargo.lock index 73d8ffb..b8d0c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,12 +93,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -161,9 +178,10 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "base64 0.22.1", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -179,8 +197,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -196,7 +216,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -218,10 +238,10 @@ dependencies = [ "axum-core", "bytes", "cookie", - "fastrand", + "fastrand 2.3.0", "futures-util", "headers", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -239,6 +259,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -265,6 +297,18 @@ dependencies = [ "serde", ] +[[package]] +name = "binstring" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -324,11 +368,12 @@ version = "0.1.0" dependencies = [ "axum", "axum-extra", - "base64", + "base64 0.22.1", "chrono", "cookie", "dashmap", "dotenvy", + "isahc", "jsonwebtoken", "migration", "reqwest", @@ -336,13 +381,14 @@ dependencies = [ "sea-orm-migration", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-cron-scheduler", "tower-http 0.5.2", "tracing", "tracing-subscriber", "uuid", + "web-push", ] [[package]] @@ -385,6 +431,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + [[package]] name = "cc" version = "1.2.55" @@ -471,6 +523,17 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -486,6 +549,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" @@ -591,6 +660,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "ct-codecs" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" + +[[package]] +name = "curl" +version = "0.4.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.59.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.85+curl-8.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0efa6142b5ecc05f6d3eaa39e6af4888b9d3939273fb592c92b7088a8cf3fdb" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.59.0", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -667,17 +773,56 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +dependencies = [ + "const-oid 0.6.2", + "der_derive", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.6.0", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "deranged" version = "0.5.5" @@ -748,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", + "const-oid 0.9.6", "crypto-common", "subtle", ] @@ -776,12 +921,30 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ece" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746" +dependencies = [ + "base64 0.21.7", + "byteorder", + "hex", + "hkdf", + "lazy_static", + "once_cell", + "openssl", + "serde", + "sha2", + "thiserror 1.0.69", ] [[package]] @@ -790,8 +953,18 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-compact" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd" +dependencies = [ + "ct-codecs", + "getrandom 0.3.4", ] [[package]] @@ -830,8 +1003,8 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core", "sec1", "subtle", @@ -874,6 +1047,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -885,6 +1064,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1024,6 +1212,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1081,9 +1297,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1114,7 +1332,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1169,10 +1387,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -1184,7 +1402,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.0", ] [[package]] @@ -1223,6 +1441,30 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "hmac-sha256" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d" +dependencies = [ + "digest", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5075d41b75a022af043a5bbf49b89abf17665d5aebf6f6ec64ffff207d87654d" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1232,6 +1474,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1249,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1260,7 +1513,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -1288,7 +1541,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -1306,7 +1559,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -1338,11 +1591,11 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", @@ -1510,6 +1763,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1532,6 +1794,33 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "isahc" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +dependencies = [ + "async-channel", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener 2.5.3", + "futures-lite 1.13.0", + "http 0.2.12", + "log", + "mime", + "once_cell", + "polling", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1554,23 +1843,63 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac", "js-sys", "p256", "p384", - "pem", + "pem 3.0.6", "rand", - "rsa", + "rsa 0.9.10", "serde", "serde_json", "sha2", - "signature", + "signature 2.2.0", "simple_asn1", ] +[[package]] +name = "jwt-simple" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357892bb32159d763abdea50733fadcb9a8e1c319a9aa77592db8555d05af83e" +dependencies = [ + "anyhow", + "binstring", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand", + "rsa 0.7.2", + "serde", + "serde_json", + "spki 0.6.0", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature 2.2.0", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1592,13 +1921,23 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libnghttp2-sys" +version = "0.1.11+1.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6c24e48a7167cffa7119da39d577fa482e66c688a4aac016bee862e1a713c4" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall 0.7.0", ] @@ -1613,6 +1952,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1704,7 +2055,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1735,7 +2086,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1829,7 +2180,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1953,16 +2304,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" +dependencies = [ + "base64 0.13.1", + "once_cell", + "regex", +] + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2005,6 +2376,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2017,15 +2408,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff33bdbdfc54cc98a2eca766ebdec3e1b8fb7387523d5c9c9a2891da856f719" +dependencies = [ + "der 0.6.1", + "pkcs8 0.9.0", + "spki 0.6.0", + "zeroize", +] + [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", ] [[package]] @@ -2034,8 +2447,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -2044,6 +2457,22 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2207,7 +2636,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2216,7 +2645,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2263,12 +2692,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -2350,22 +2779,43 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rsa" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "094052d5470cbcef561cb848a7209968c9f12dfa6d668f4bca048ac5de51099c" +dependencies = [ + "byteorder", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1 0.4.1", + "pkcs8 0.9.0", + "rand_core", + "signature 1.6.4", + "smallvec", + "subtle", + "zeroize", +] + [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest", "num-bigint-dig", "num-integer", "num-traits", - "pkcs1", - "pkcs8", + "pkcs1 0.7.5", + "pkcs8 0.10.2", "rand_core", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -2401,7 +2851,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2505,7 +2955,7 @@ dependencies = [ "serde_json", "sqlx", "strum 0.26.3", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -2602,7 +3052,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2641,20 +3091,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "sec1_decode" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23" +dependencies = [ + "der 0.4.5", + "pem 0.8.3", + "thiserror 1.0.69", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -2790,6 +3251,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "signature" version = "2.2.0" @@ -2814,7 +3285,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2830,6 +3301,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel", + "futures-core", + "futures-io", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2858,6 +3340,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -2865,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -2887,14 +3379,14 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -2912,7 +3404,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -2967,9 +3459,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.10.0", "byteorder", "bytes", "chrono", @@ -2992,7 +3484,7 @@ dependencies = [ "once_cell", "percent-encoding", "rand", - "rsa", + "rsa 0.9.10", "rust_decimal", "serde", "sha1", @@ -3000,7 +3492,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3014,9 +3506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.10.0", "byteorder", "chrono", "crc", @@ -3043,7 +3535,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3070,7 +3562,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -3170,6 +3662,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3187,7 +3691,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -3214,20 +3718,40 @@ version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "fastrand", + "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -3381,6 +3905,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3446,9 +3982,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", - "http", + "http 1.4.0", "http-body", "http-body-util", "pin-project-lite", @@ -3462,10 +3998,10 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "iri-string", "pin-project-lite", @@ -3519,6 +4055,16 @@ dependencies = [ "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" @@ -3554,6 +4100,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3611,6 +4175,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3653,6 +4223,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "want" version = "0.3.1" @@ -3683,6 +4259,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -3742,6 +4327,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-push" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2332e5400bb42c21bcab3ca2cd3400ab4b1d5ecbe276b533ce9acb59c56602" +dependencies = [ + "async-trait", + "base64 0.13.1", + "chrono", + "ece", + "futures-lite 2.6.1", + "http 0.2.12", + "isahc", + "jwt-simple", + "log", + "pem 3.0.6", + "sec1_decode", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -3868,6 +4475,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4128,7 +4744,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -4169,7 +4785,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure", + "synstructure 0.13.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6d3bcb2..9878cd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -axum = "0.7" +axum = { version = "0.7", features = ["ws"] } tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] } sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] } sea-orm-migration = "1.1" @@ -22,6 +22,8 @@ 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" diff --git a/frontend/index.html b/frontend/index.html index ea8c9bd..edcefbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,9 +3,13 @@ - + + + + + + Antigravity Agent Dashboard - +
+ + +
⌘
@@ -63,22 +75,56 @@
-

Recent Activity

-

Track latest agent executions across all directives.

+

Perspective

+

Overview of recent agent directives and live communication.

+ +
+ + +
-
- - - - - - - - - - - -
DirectiveStatusDate
+ +
+
+
+
+

Neural Link

+ +
+
+ +
+

Establish connection with the neural assistant.

+
+
+
+ + +
+
+
+ +
+
+ + + + + + + + + + + +
DirectiveStatusDate
+
+
@@ -98,6 +144,9 @@ +
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..a5ea0e4 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..b23b3c7 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..2905528 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..5978a0c --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Antigravity Agency Dashboard", + "short_name": "Agency", + "description": "Autonomous AI Agent Dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0c", + "theme_color": "#5d5dff", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..e976c34 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,119 @@ +const CACHE_NAME = 'agency-cache-v4'; +const ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/icon-192.png', + '/icon-512.png' +]; + +// Force immediate update to the latest SW +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS); + }).then(() => self.skipWaiting()) + ); +}); + +// Clean up old caches and take control of all clients immediately +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + // Only intercept http/https requests + if (!event.request.url.startsWith('http')) return; + + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(event.request).catch((error) => { + // If network fetch fails and it's a navigation request, return index.html + if (event.request.mode === 'navigate') { + return caches.match('/index.html'); + } + + // For assets, return a failure response instead of throwing. + // Re-throwing (or returning a rejected promise) causes the browser to show + // the "unexpected error" interception UI. + console.warn('Fetch failed for:', event.request.url, error); + + return new Response('Network error occurred', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ 'Content-Type': 'text/plain' }) + }); + }); + }) + ); +}); + +self.addEventListener('push', (event) => { + let data = { title: 'Notification', body: 'New update from Agency' }; + try { + if (event.data) { + data = event.data.json(); + } + } catch (e) { + console.error('Error parsing push data:', e); + } + + const options = { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: '1', + taskId: data.task_id, + runId: data.run_id + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const taskId = event.notification.data.taskId; + const runId = event.notification.data.runId; + + let url = '/'; + if (taskId && runId) { + url = `/?taskId=${taskId}&runId=${runId}`; + } + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { + // Check if there is already a window open and focus it, or open a new one + for (let client of windowClients) { + if ('focus' in client) { + // Navigate the existing client to the new URL if it's the same app + return client.navigate(url).then(c => c.focus()); + } + } + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); diff --git a/frontend/src/main.js b/frontend/src/main.js index 64ef57e..ffae909 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,5 +1,6 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; +import './style.css'; const API_URL = '/api'; // These should ideally be environment-specific @@ -16,7 +17,10 @@ const state = { selectedRunId: null, currentView: 'dashboard', // 'dashboard' or 'task' isEditing: false, - isAuthenticated: false + isAuthenticated: false, + chatMessages: [], + activeDashboardTab: 'chat', // 'chat' or 'activity' + swRegistration: null }; // DOM elements const loginOverlay = document.getElementById('login-overlay'); @@ -53,6 +57,14 @@ 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); @@ -89,6 +101,7 @@ function renderApp() { if (state.currentView === 'dashboard') { fetchRecentRuns(); + renderChat(); } else if (state.selectedTaskId) { const task = state.tasks.find(t => t.id === state.selectedTaskId); if (task) { @@ -146,6 +159,16 @@ async function fetchTasks() { 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; if (state.selectedTaskId) { @@ -166,6 +189,12 @@ async function fetchTasks() { tasks: newTasks, selectedRunId: newSelectedRunId }); + + // If we just loaded from a deep link, clear the params and select it + if (urlTaskId) { + window.history.replaceState({}, document.title, "/"); + selectTask(urlTaskId, urlRunId); + } } catch (error) { console.error('Error fetching tasks:', error); } @@ -232,6 +261,24 @@ function selectTask(id, runId = null) { renderTaskList(); renderRunHistory(task); showTaskView(task); + + // Fetch and update subscription status + fetch(`${API_URL}/tasks/${id}/subscription`) + .then(res => res.json()) + .then(data => { + const btn = document.getElementById('notify-task-btn'); + if (data.isSubscribed) { + btn.classList.add('notified'); + } else { + btn.classList.remove('notified'); + } + }) + .catch(err => console.error('Failed to fetch subscription status', err)); + + // Close sidebar on mobile after selection + if (window.innerWidth <= 768) { + closeMobileMenu(); + } } function renderRunHistory(task) { @@ -264,6 +311,28 @@ function showDashboard() { renderTaskList(); fetchRecentRuns(); + renderDashboardTabs(); +} + +function renderDashboardTabs() { + const tabs = document.querySelectorAll('.tab-btn'); + const panels = document.querySelectorAll('.tab-panel'); + + tabs.forEach(tab => { + if (tab.dataset.tab === state.activeDashboardTab) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + panels.forEach(panel => { + if (panel.id === `${state.activeDashboardTab}-tab-panel`) { + panel.classList.add('active'); + } else { + panel.classList.remove('active'); + } + }); } function renderDashboard(recentRuns) { @@ -354,6 +423,62 @@ function escapeHtml(text) { return div.innerHTML; } +function renderChat() { + if (!chatMessagesEl) return; + + if (state.chatMessages.length === 0) { + chatMessagesEl.innerHTML = ` +
+

Start a conversation with the assistant.

+
+ `; + return; + } + + chatMessagesEl.innerHTML = state.chatMessages + .map(msg => ` +
+ ${DOMPurify.sanitize(marked.parse(msg.content || ''))} +
+ `) + .join(''); + + chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; +} + +async function sendChatMessage(text) { + const userMessage = { role: 'user', content: text }; + state.chatMessages.push(userMessage); + renderChat(); + + chatInput.value = ''; + chatInput.disabled = true; + chatSendBtn.disabled = true; + + try { + const response = await fetchWithAuth(`${API_URL}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: state.chatMessages }) + }); + + if (!response.ok) throw new Error('Chat API failed'); + + const result = await response.json(); + state.chatMessages.push(result.message); + renderChat(); + } catch (error) { + console.error('Chat error:', error); + showToast('Failed to get chat response.', 'error'); + state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }); + renderChat(); + } finally { + chatInput.disabled = false; + chatSendBtn.disabled = false; + chatInput.focus(); + } +} + // Event Listeners rerunBtn.addEventListener('click', async () => { if (!state.selectedTaskId) return; @@ -447,6 +572,19 @@ toggleCustomCronBtn.addEventListener('click', () => { customCronContainer.classList.toggle('hidden'); }); +chatForm.addEventListener('submit', (e) => { + e.preventDefault(); + const text = chatInput.value.trim(); + if (text) { + sendChatMessage(text); + } +}); + +clearChatBtn.addEventListener('click', () => { + state.chatMessages = []; + renderChat(); +}); + cronInput.addEventListener('input', () => { // If user types manually, update presets active state updateScheduleUI(cronInput.value); @@ -511,17 +649,86 @@ async function checkSession() { return false; } -let isPolling = false; -async function startAutoRefresh() { - setInterval(async () => { - if (isPolling) return; - isPolling = true; +let socket = null; +let reconnectDelay = 1000; + +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/ws`; + + console.log('Connecting to WebSocket:', wsUrl); + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('WebSocket connected'); + reconnectDelay = 1000; + // Initial fetch to sync state + fetchTasks(); + }; + + socket.onmessage = (event) => { try { - await fetchTasks(); - } finally { - isPolling = false; + 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); } - }, 3000); + }; + + socket.onclose = () => { + console.log('WebSocket disconnected. Reconnecting...'); + setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 1.5, 30000); + connectWebSocket(); + }, reconnectDelay); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + socket.close(); + }; } async function showLogin() { @@ -552,7 +759,14 @@ async function handleCallback() { const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, { credentials: 'include' }); - const data = await response.json(); + const contentType = response.headers.get("content-type"); + let data; + if (contentType && contentType.includes("application/json")) { + data = await response.json(); + } else { + const text = await response.text(); + throw new Error(`Expected JSON but got ${contentType}. Body: ${text.substring(0, 100)}`); + } if (response.ok) { state.isAuthenticated = true; @@ -578,14 +792,48 @@ 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(); - startAutoRefresh(); + connectWebSocket(); } else { showLogin(); } @@ -597,3 +845,116 @@ if (window.location.pathname === '/callback' || window.location.search.includes( } else { initializeApp(); } + +// Register Service Worker for PWA +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js') + .then(reg => { + console.log('SW registered', reg); + state.swRegistration = reg; + }) + .catch(err => { + console.error('SW registration failed:', err); + if (window.isSecureContext === false) { + console.error('Context is NOT secure. Service Workers require HTTPS or localhost.'); + } + }); + }); +} + +async function setupPush() { + if (!state.swRegistration) { + console.warn('SW registration not available'); + return false; + } + + try { + const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`); + const { publicKey } = await vapidResponse.json(); + + // Always clear existing subscription to ensure we use latest VAPID key + const existingSub = await state.swRegistration.pushManager.getSubscription(); + if (existingSub) { + await existingSub.unsubscribe(); + console.log('Unsubscribed existing push subscription'); + } + + const subscription = await state.swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) + }); + + await fetch(`${API_URL}/notifications/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + p256dh: b64(subscription.getKey('p256dh')), + auth: b64(subscription.getKey('auth')) + }) + }); + console.log('Push registered'); + return true; + } catch (err) { + console.warn('Push registration failed:', err); + return false; + } +} + +function b64(buffer) { + const binary = String.fromCharCode.apply(null, new Uint8Array(buffer)); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +async function toggleTaskSubscription(taskId) { + const btn = document.getElementById('notify-task-btn'); + const isNotified = btn.classList.contains('notified'); + const method = isNotified ? 'DELETE' : 'POST'; + + // If trying to enable but no push subscription, try setting it up first (user gesture here) + if (!isNotified && 'Notification' in window) { + if (Notification.permission !== 'granted') { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + showToast('Notification permission denied', 'error'); + return; + } + } + + const sub = await state.swRegistration.pushManager.getSubscription(); + if (!sub) { + const success = await setupPush(); + if (!success) { + showToast('Failed to initialize push notifications', 'error'); + return; + } + } + } + + try { + await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method }); + btn.classList.toggle('notified'); + showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled'); + } catch (err) { + showToast('Failed to update notifications'); + } +} + +document.getElementById('notify-task-btn').addEventListener('click', () => { + if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css index 0761f29..c2c08b6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -25,6 +25,7 @@ body { color: var(--text-main); line-height: 1.5; height: 100vh; + height: 100dvh; overflow: hidden; } @@ -88,9 +89,15 @@ body { .dashboard-content { flex: 1; overflow-y: auto; - border-radius: 12px; + border-radius: 16px; display: flex; flex-direction: column; + width: 100%; + max-width: 900px; + background: rgba(255, 255, 255, 0.02); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + border: 1px solid var(--glass-border); + min-height: 0; } .activity-table { @@ -124,6 +131,158 @@ body { background: rgba(255, 255, 255, 0.03); } +.dashboard-content-grid { + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + flex: 1; + min-height: 0; + width: 100%; +} + +.dashboard-tabs { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.tab-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + color: var(--text-dim); + padding: 10px 20px; + border-radius: 100px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + gap: 8px; +} + +.tab-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); +} + +.tab-btn.active { + background: var(--primary); + color: white; + border-color: var(--primary); + box-shadow: 0 4px 15px var(--primary-glow); +} + +.tab-panel { + display: none; + width: 100%; + flex-direction: column; + align-items: center; + flex: 1; + min-height: 0; +} + +.tab-panel.active { + display: flex; +} + +.chat-container { + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); + width: 100%; + max-width: 900px; + flex: 1; + min-height: 0; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + border: 1px solid var(--glass-border); +} + +.chat-header { + padding: 16px 20px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--glass-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.chat-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.chat-messages { + flex: 1; + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-empty-state { + margin: auto; + text-align: center; + color: var(--text-dim); + font-style: italic; + font-size: 13px; + opacity: 0.6; +} + +.chat-message { + max-width: 80%; + padding: 12px 18px; + border-radius: 16px; + font-size: 15px; + line-height: 1.5; + word-wrap: break-word; +} + +.chat-message.user { + align-self: flex-end; + background: var(--primary); + color: white; + border-bottom-right-radius: 4px; +} + +.chat-message.assistant { + align-self: flex-start; + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border-bottom-left-radius: 4px; +} + +.chat-form { + padding: 16px; + background: rgba(255, 255, 255, 0.02); + border-top: 1px solid var(--glass-border); + display: flex; + gap: 10px; +} + +.chat-form input { + flex: 1; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 12px 20px; + color: var(--text-main); + font-size: 15px; + outline: none; + transition: var(--transition); +} + +.chat-form input:focus { + border-color: var(--primary); +} + .activity-table .status-badge { display: inline-block; } @@ -796,9 +955,9 @@ textarea:focus { } .btn-sm { - padding: 6px 12px; + padding: 6px 16px; font-size: 12px; - width: 100%; + width: auto; justify-content: center; } @@ -885,4 +1044,242 @@ textarea:focus { .logout-btn:hover { color: var(--primary); +} + +/* Mobile Menu Toggle */ +.menu-toggle { + display: none; + position: fixed; + top: calc(16px + env(safe-area-inset-top)); + right: calc(16px + env(safe-area-inset-right)); + z-index: 1100; + background: var(--primary); + border: none; + width: 44px; + height: 44px; + border-radius: 10px; + cursor: pointer; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + box-shadow: 0 4px 15px var(--primary-glow); + transition: var(--transition); +} + +.menu-toggle .bar { + display: block; + width: 20px; + height: 2px; + background: white; + border-radius: 2px; + transition: var(--transition); +} + +.menu-toggle.active .bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} + +.menu-toggle.active .bar:nth-child(2) { + opacity: 0; +} + +.menu-toggle.active .bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +.sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 1000; + transition: opacity 0.3s ease; +} + +/* Responsive Styles */ +#notify-task-btn.notified { + color: var(--primary); + background: var(--primary-glow); +} + +#notify-task-btn.notified .notify-icon { + animation: ring 0.5s ease; +} + +@keyframes ring { + 0% { + transform: rotate(0); + } + + 25% { + transform: rotate(15deg); + } + + 50% { + transform: rotate(-15deg); + } + + 75% { + transform: rotate(10deg); + } + + 100% { + transform: rotate(0); + } +} + +@media (max-width: 768px) { + .menu-toggle { + display: flex; + } + + .sidebar { + position: fixed; + left: -320px; + top: 0; + height: 100%; + z-index: 1050; + transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5); + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } + + .sidebar.open { + left: 0; + } + + .dashboard-view, + .task-view { + padding: 20px; + padding-top: calc(80px + env(safe-area-inset-top)); + padding-left: max(20px, env(safe-area-inset-left)); + padding-right: max(20px, env(safe-area-inset-right)); + padding-bottom: max(20px, env(safe-area-inset-bottom)); + height: auto; + min-height: 100%; + overflow-y: auto; + overflow-x: hidden; + width: 100%; + max-width: 100vw; + } + + .dashboard-header h2 { + font-size: 24px; + } + + .task-content { + flex-direction: column; + height: auto; + overflow: visible; + width: 100%; + gap: 16px; + } + + .run-history { + width: 100%; + max-height: 200px; + flex-shrink: 0; + margin-bottom: 8px; + } + + .run-details { + flex-direction: column; + height: auto; + overflow: visible; + gap: 16px; + width: 100%; + } + + .answer-container { + width: 100%; + margin-bottom: 8px; + border-left: none; + border-top: 4px solid var(--primary); + } + + .logs-container { + width: 100%; + height: 400px; + flex-shrink: 0; + } + + .view-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .header-main { + flex-direction: column; + align-items: flex-start; + gap: 12px; + width: 100%; + } + + .header-main h2 { + font-size: 20px; + line-height: 1.3; + } + + .header-actions { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .header-actions .btn { + flex: 1 1 auto; + justify-content: center; + } + + .modal { + width: 95%; + padding: 20px; + max-height: 90%; + overflow-y: auto; + } + + .preset-group { + grid-template-columns: 1fr; + } + + .chat-container { + height: 500px; + } + + .chat-form input, + textarea { + font-size: 16px !important; + } + + /* Force word break for long text in containers */ + .answer-output, + .activity-table td { + word-break: break-word; + } + + .activity-table td { + padding: 12px 10px; + } + + /* Hide Date on very small mobile to prevent table overflow */ + @media (max-width: 480px) { + + .activity-table th:last-child, + .activity-table td:last-child { + display: none; + } + } + + /* Prevent horizontal scroll on the entire app */ + #app { + width: 100vw; + overflow-x: hidden; + } } \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7ce721b..a06ef36 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,8 +4,9 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:3000', + target: 'http://localhost:3001', changeOrigin: true, + ws: true, } } } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index e206140..cad08e0 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -4,6 +4,7 @@ mod m20220101_000001_create_table; mod m20260210_000002_add_answer_column; mod m20260210_000003_separate_runs; mod m20260210_000004_add_cron_column; +mod m20260212_000005_notifications; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20260210_000002_add_answer_column::Migration), Box::new(m20260210_000003_separate_runs::Migration), Box::new(m20260210_000004_add_cron_column::Migration), + Box::new(m20260212_000005_notifications::Migration), ] } } diff --git a/migration/src/m20260212_000005_notifications.rs b/migration/src/m20260212_000005_notifications.rs new file mode 100644 index 0000000..fdfdaac --- /dev/null +++ b/migration/src/m20260212_000005_notifications.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Push Subscriptions table + manager + .create_table( + Table::create() + .table(PushSubscriptions::Table) + .if_not_exists() + .col( + ColumnDef::new(PushSubscriptions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(PushSubscriptions::UserSub) + .string() + .not_null(), + ) + .col( + ColumnDef::new(PushSubscriptions::Endpoint) + .string() + .not_null(), + ) + .col( + ColumnDef::new(PushSubscriptions::P256dh) + .string() + .not_null(), + ) + .col(ColumnDef::new(PushSubscriptions::Auth).string().not_null()) + .col( + ColumnDef::new(PushSubscriptions::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // Task Subscriptions table + manager + .create_table( + Table::create() + .table(TaskSubscriptions::Table) + .if_not_exists() + .col( + ColumnDef::new(TaskSubscriptions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(TaskSubscriptions::UserSub) + .string() + .not_null(), + ) + .col(ColumnDef::new(TaskSubscriptions::TaskId).uuid().not_null()) + .col( + ColumnDef::new(TaskSubscriptions::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk-task-subscription-task-id") + .from(TaskSubscriptions::Table, TaskSubscriptions::TaskId) + .to(Tasks::Table, Tasks::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TaskSubscriptions::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(PushSubscriptions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum PushSubscriptions { + Table, + Id, + UserSub, + Endpoint, + P256dh, + Auth, + CreatedAt, +} + +#[derive(DeriveIden)] +enum TaskSubscriptions { + Table, + Id, + UserSub, + TaskId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Tasks { + Table, + Id, +} diff --git a/openapi/calendar/openapi.json b/openapi/calendar/openapi.json new file mode 100644 index 0000000..8c8908c --- /dev/null +++ b/openapi/calendar/openapi.json @@ -0,0 +1,593 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "calendar", + "description": "", + "license": { + "name": "" + }, + "version": "0.1.0" + }, + "paths": { + "/auth/me": { + "get": { + "tags": [ + "crate::handlers::auth" + ], + "operationId": "me", + "responses": { + "200": { + "description": "Current user profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUser" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "oidc": [] + } + ] + } + }, + "/events": { + "get": { + "tags": [ + "crate::handlers::event" + ], + "operationId": "list_events", + "parameters": [ + { + "name": "upcoming", + "in": "query", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Model" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "post": { + "tags": [ + "crate::handlers::event" + ], + "operationId": "create_event", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "oidc": [] + } + ] + } + }, + "/events/{id}": { + "get": { + "tags": [ + "crate::handlers::event" + ], + "operationId": "get_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Event details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "put": { + "tags": [ + "crate::handlers::event" + ], + "operationId": "update_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "delete": { + "tags": [ + "crate::handlers::event" + ], + "operationId": "delete_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Event deleted successfully" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + } + }, + "/service/v1/events": { + "get": { + "tags": [ + "crate::handlers::service" + ], + "operationId": "service_list_events", + "parameters": [ + { + "name": "user_id", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + }, + { + "name": "upcoming", + "in": "query", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Model" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "post": { + "tags": [ + "crate::handlers::service" + ], + "operationId": "service_create_event", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceCreateEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "oidc": [] + } + ] + } + }, + "/service/v1/events/{id}": { + "get": { + "tags": [ + "crate::handlers::service" + ], + "operationId": "service_get_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Event details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "put": { + "tags": [ + "crate::handlers::service" + ], + "operationId": "service_update_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceCreateEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + }, + "delete": { + "tags": [ + "crate::handlers::service" + ], + "operationId": "service_delete_event", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Event database id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Event deleted successfully" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Event not found" + } + }, + "security": [ + { + "oidc": [] + } + ] + } + } + }, + "components": { + "schemas": { + "CreateEventRequest": { + "type": "object", + "required": [ + "name", + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "name": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + "CurrentUser": { + "type": "object", + "required": [ + "id", + "sub", + "email", + "name" + ], + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "sub": { + "type": "string" + } + } + }, + "Model": { + "type": "object", + "required": [ + "id", + "name", + "from", + "to" + ], + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "ServiceCreateEventRequest": { + "type": "object", + "required": [ + "name", + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "name": { + "type": "string" + }, + "to": { + "type": "string" + }, + "user_id": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + } + } + }, + "tags": [ + { + "name": "calendar", + "description": "Calendar Management API" + } + ] +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index c7a714e..b81a727 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { 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 { @@ -54,6 +56,12 @@ impl Config { .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, @@ -66,6 +74,8 @@ impl Config { cookie_secure, agent_max_turns, agent_max_duration_secs, + vapid_private_key, + calendar_api_url, }) } } diff --git a/src/domain/agent/api.rs b/src/domain/agent/api.rs index 2d2ae37..cbd93a8 100644 --- a/src/domain/agent/api.rs +++ b/src/domain/agent/api.rs @@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Message { pub role: String, - #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, @@ -29,6 +28,7 @@ pub struct FunctionCall { pub struct ChatRequest { pub model: String, pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, } @@ -72,8 +72,11 @@ pub async fn perform_search( query: &str, api_key: &str, ) -> Result> { + tracing::info!(query = %query, "Performing Tavily web search"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .pool_idle_timeout(std::time::Duration::from_secs(60)) .build()?; let response = client .post("https://api.tavily.com/search") @@ -94,6 +97,7 @@ pub async fn perform_search( } let search_data: TavilyResponse = response.json().await?; + tracing::info!("Web search yielded {} results", search_data.results.len()); let mut results_text = String::new(); for (i, result) in search_data.results.iter().enumerate() { diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs index 9f6933f..8f738cc 100644 --- a/src/domain/agent/mod.rs +++ b/src/domain/agent/mod.rs @@ -2,16 +2,21 @@ 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, - messages: Vec, + calendar_client: Arc, + pub user_sub: Option, + pub messages: Vec, tools: Option>, logs: String, answer: Option, @@ -21,8 +26,11 @@ 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!( @@ -47,18 +55,42 @@ impl Agent { }, ]; + Self::with_messages( + db, + zen_api_key, + tavily_api_key, + calendar_client, + user_sub, + messages, + ) + } + + pub fn with_messages( + db: DatabaseConnection, + zen_api_key: Option, + 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(60)) + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .tcp_keepalive(std::time::Duration::from_secs(30)) + .pool_idle_timeout(std::time::Duration::from_secs(60)) .build() .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(), @@ -90,6 +122,8 @@ impl Agent { return Err(AppError::Internal("Agent run exceeded max turns".into())); } + tracing::info!("Turn {}", turns); + turns += 1; let current_role = self .messages @@ -101,6 +135,59 @@ impl Agent { 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 @@ -109,28 +196,39 @@ impl Agent { .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 { + 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) - .await - .map_err(|e| { - AppError::Internal(format!("Tool execution failed: {}", e)) - })?; + 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); - self.log("Task marked as finished by tool."); + self.answer = Some(ans.clone()); + final_answer = Some(ans); + self.log("Interaction marked as finished by tool."); } if let Some(content) = &tool_message.content { @@ -139,20 +237,25 @@ impl Agent { self.messages.push(tool_message); if is_final { - finished = true; + is_final_cycle = true; } } - } else if assistant_message.content.is_some() { - // If assistant just talked without tools, we might be stuck or finished. - // But typically we expect a 'finish' tool call. - self.log("Assistant responded without tool calls."); - // For now we continue unless the assistant explicitly uses a tool to finish, - // or we could add heuristic here if needed. - } - } - self.log("\n--- Execution Finished ---"); - Ok((self.logs.clone(), self.answer.clone())) + 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 { @@ -162,29 +265,58 @@ impl Agent { tools: self.tools.clone(), }; - let mut request_builder = self.client.post(&self.url).json(&request); + let mut request_builder = self + .client + .post(&self.url) + .json(&request) + .timeout(Duration::from_secs(60)); if let Some(key) = &self.zen_api_key { request_builder = request_builder.header("Authorization", format!("Bearer {}", key)); } - let response = request_builder.send().await.map_err(AppError::Network)?; + let start = std::time::Instant::now(); + let response = request_builder.send().await.map_err(|e| { + let duration = start.elapsed(); + let is_timeout = e.is_timeout(); + let is_connect = e.is_connect(); + tracing::error!( + "Network error after {:?} during LLM call (Timeout: {}, Connect: {}): {:?}", + duration, + is_timeout, + is_connect, + e + ); + AppError::Network(e) + })?; + + let duration = start.elapsed(); + tracing::info!("LLM request completed in {:?}", duration); if !response.status().is_success() { let status = response.status(); - let error_text = response + let body_text = response .text() .await - .unwrap_or_else(|_| "Unknown error".into()); - return Err(AppError::Internal(format!( - "API request failed: {} - {}", - status, error_text - ))); + .unwrap_or_else(|_| "Unknown body".into()); + let err = format!("API request failed: {} - {}", status, body_text); + tracing::error!("{}", err); + return Err(AppError::Internal(err)); } - response - .json() - .await - .map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e))) + 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 index 663b1b1..2b257c0 100644 --- a/src/domain/agent/tools.rs +++ b/src/domain/agent/tools.rs @@ -1,5 +1,10 @@ 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 { @@ -11,6 +16,13 @@ struct FinishArgs { result: String, } +#[derive(Deserialize)] +struct ListRunsArgs { + from: Option, + to: Option, + task_ids: Option>, +} + pub fn get_tools() -> Vec { vec![ Tool { @@ -47,12 +59,94 @@ pub fn get_tools() -> Vec { }), }, }, + 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; @@ -76,6 +170,106 @@ pub async fn handle_tool_call( 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) }; diff --git a/src/domain/auth.rs b/src/domain/auth.rs index c70e0c7..a180185 100644 --- a/src/domain/auth.rs +++ b/src/domain/auth.rs @@ -46,7 +46,10 @@ pub struct JwksVerifier { impl JwksVerifier { pub async fn new(issuer: String, audience: String) -> Result> { - let client = Client::new(); + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build()?; let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') @@ -123,7 +126,10 @@ impl Authenticator { client_id: String, client_secret: String, ) -> Result> { - let client = Client::new(); + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build()?; let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') @@ -190,4 +196,27 @@ impl Authenticator { Ok(res) } + + pub async fn client_credentials( + &self, + scope: &str, + ) -> Result> { + let params = [ + ("grant_type", "client_credentials"), + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ("scope", scope), + ]; + + let res = self + .client + .post(&self.token_url) + .form(¶ms) + .send() + .await? + .json() + .await?; + + Ok(res) + } } diff --git a/src/domain/calendar/mod.rs b/src/domain/calendar/mod.rs new file mode 100644 index 0000000..c6f49ae --- /dev/null +++ b/src/domain/calendar/mod.rs @@ -0,0 +1,298 @@ +use crate::domain::auth::Authenticator; +use crate::error::{AppError, AppResult}; +use chrono::{DateTime, Duration, Utc}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarEvent { + pub id: Option, + pub name: String, + pub from: DateTime, + pub to: DateTime, + pub user_sub: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateEventRequest { + pub name: String, + pub from: String, + pub to: String, + pub user_sub: Option, +} +struct TokenState { + access_token: String, + expires_at: DateTime, +} + +pub struct CalendarClient { + base_url: String, + client: Client, + authenticator: Arc, + token_state: RwLock>, +} + +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 index 7982060..4a21816 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,5 @@ 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 new file mode 100644 index 0000000..ec19098 --- /dev/null +++ b/src/domain/notifications/mod.rs @@ -0,0 +1 @@ +pub mod push; diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs new file mode 100644 index 0000000..478fe2a --- /dev/null +++ b/src/domain/notifications/push.rs @@ -0,0 +1,113 @@ +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use web_push::*; + +pub struct PushSender { + pub private_key: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PushSubscription { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +fn ensure_pem(input: &str) -> String { + let input = input.trim(); + if input.contains("-----BEGIN") { + return input.to_string(); + } + + if input.starts_with("MHc") { + format!( + "-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----", + input + ) + } else { + format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----", + input + ) + } +} + +impl PushSender { + pub fn new(private_key_pem: &str) -> AppResult { + 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 index 5092091..bd518ef 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -8,6 +8,7 @@ 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)] @@ -22,7 +23,7 @@ pub struct UpdateTaskRequest { pub cron: Option, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct TaskResponse { pub id: Uuid, pub goal: String, @@ -31,7 +32,7 @@ pub struct TaskResponse { pub runs: Vec, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct TaskRunResponse { pub id: Uuid, pub status: String, @@ -40,7 +41,7 @@ pub struct TaskRunResponse { pub created_at: chrono::DateTime, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct RecentRunResponse { pub id: Uuid, pub task_id: Uuid, @@ -55,11 +56,12 @@ 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 agent execution run"); + tracing::info!(%task_id, %run_id, "Starting background agent execution run"); let new_run = task_run::ActiveModel { id: Set(run_id), @@ -75,42 +77,106 @@ pub async fn execute_agent_run( .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 (logs, answer, status) = match agent.run(config).await { - Ok((logs, answer)) => { - tracing::info!(%task_id, %run_id, "Agent execution completed successfully"); - (logs, answer, "completed".to_string()) + 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"); } - Err(e) => { - tracing::error!(%task_id, %run_id, error = %e, "Agent execution failed"); - ( - format!("Execution failed: {}", e), - None, - "failed".to_string(), - ) + + 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; + }); + } + } + } + } } - }; - - let run: task_run::ActiveModel = TaskRun::find_by_id(run_id) - .one(db) - .await - .map_err(crate::error::AppError::Database)? - .ok_or_else(|| crate::error::AppError::NotFound("Run not found after insert".into()))? - .into(); - - let mut run = run; - run.logs = Set(logs.clone()); - run.answer = Set(answer.clone()); - run.status = Set(status); - - run.update(db) - .await - .map_err(crate::error::AppError::Database)?; + }); get_task_inner(task_id, db).await } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 7ff42bf..304ddb0 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,2 +1,4 @@ +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 new file mode 100644 index 0000000..c306114 --- /dev/null +++ b/src/entities/push_subscription.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "push_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub endpoint: String, + pub p256dh: String, + pub auth: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/task_subscription.rs b/src/entities/task_subscription.rs new file mode 100644 index 0000000..b7011ee --- /dev/null +++ b/src/entities/task_subscription.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "task_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub task_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::task::Entity", + from = "Column::TaskId", + to = "super::task::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Task, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Task.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/error.rs b/src/error.rs index 01943d5..81194ce 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,16 +32,20 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, error_message) = match self { + let (status, error_message) = match &self { AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), - AppError::NotFound(err) => (StatusCode::NOT_FOUND, err), - AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err), - AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), + 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), + 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, })); diff --git a/src/scheduler.rs b/src/scheduler.rs index 2ee8a01..c98debd 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -13,12 +13,18 @@ pub struct Scheduler { db: DatabaseConnection, tasks_to_jobs: DashMap, config: Arc, + pub calendar_client: Arc, + pub tx: tokio::sync::broadcast::Sender, + pub push_sender: Arc, } 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 @@ -32,6 +38,9 @@ impl Scheduler { db, tasks_to_jobs: DashMap::new(), config, + calendar_client, + tx, + push_sender, }) } @@ -43,12 +52,20 @@ impl Scheduler { let db = self.db.clone(); let config = self.config.clone(); + let tx = self.tx.clone(); + let push_sender = self.push_sender.clone(); + let calendar_client = self.calendar_client.clone(); let job = Job::new_async(cron_expr, move |_uuid, _l| { let db = db.clone(); let config = config.clone(); + let tx = tx.clone(); + let push_sender = push_sender.clone(); + let calendar_client = calendar_client.clone(); Box::pin(async move { - if let Err(e) = Self::run_task(db, config, task_id).await { + if let Err(e) = + Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await + { tracing::error!("Error in scheduled task {}: {}", task_id, e); } }) @@ -81,6 +98,9 @@ impl Scheduler { async fn run_task( db: DatabaseConnection, config: Arc, + calendar_client: Arc, + tx: tokio::sync::broadcast::Sender, + push_sender: Arc, task_id: Uuid, ) -> AppResult<()> { let task = Task::find_by_id(task_id) @@ -105,10 +125,23 @@ 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(), + }, + )); + // 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(), )?; @@ -130,7 +163,7 @@ impl Scheduler { let run_complete = task_run::ActiveModel { id: Set(run_id), - status: Set(status), + status: Set(status.clone()), logs: Set(logs), answer: Set(answer), ..Default::default() @@ -142,6 +175,52 @@ impl Scheduler { e ); } + + if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await { + let _ = tx.send(crate::server::notifications::WsEvent::RunFinished( + task_response.clone(), + )); + + // Push notifications for scheduled runs + use crate::entities::{push_subscription, task_subscription}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + if let Ok(subscriptions) = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id)) + .all(&db) + .await + { + for sub in subscriptions { + if let Ok(push_subs) = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .all(&db) + .await + { + for push_sub in push_subs { + let sender = push_sender.clone(); + let sub_data = + crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; + let goal = task_response.goal.clone(); + let status = status.clone(); + tokio::spawn(async move { + let _ = sender + .send_notification( + &sub_data, + &format!("Scheduled Task Completed: {}", status), + &goal, + Some(task_id), + Some(run_id), + ) + .await; + }); + } + } + } + } + } }); Ok(()) diff --git a/src/server/chat.rs b/src/server/chat.rs new file mode 100644 index 0000000..6e24d4b --- /dev/null +++ b/src/server/chat.rs @@ -0,0 +1,68 @@ +use crate::domain::agent::api::Message; +use crate::error::AppError; +use crate::server::AppState; +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct ChatPayload { + pub messages: Vec, +} + +#[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 index 79d28e2..5160b4e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,6 @@ pub mod auth; +pub mod chat; +pub mod notifications; pub mod tasks; use axum::{ @@ -11,7 +13,6 @@ use sea_orm::{Database, DatabaseConnection, EntityTrait}; use std::sync::Arc; use tower_http::cors::{AllowOrigin, CorsLayer}; -use crate::entities::task::Entity as Task; use crate::scheduler::Scheduler; use crate::error::AppResult; @@ -23,6 +24,8 @@ pub struct AppState { 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<()> { @@ -30,13 +33,32 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { 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()) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, + 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 @@ -47,14 +69,14 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { } } - let (verifier, authenticator) = setup_auth(&config).await?; - let state = Arc::new(AppState { db, scheduler, config: config.clone(), verifier, authenticator, + calendar_client, + tx, }); let app = build_app(state, &config); @@ -120,6 +142,13 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .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, @@ -137,6 +166,22 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .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 diff --git a/src/server/notifications.rs b/src/server/notifications.rs new file mode 100644 index 0000000..0786d0f --- /dev/null +++ b/src/server/notifications.rs @@ -0,0 +1,48 @@ +pub mod push_handlers; + +use crate::domain::tasks::{RecentRunResponse, TaskResponse}; +use crate::server::AppState; +use axum::{ + extract::{ + State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, +}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Serialize, Clone, Debug)] +#[serde(tag = "type", content = "data")] +pub enum WsEvent { + TaskCreated(TaskResponse), + TaskUpdated(TaskResponse), + RunStarted(RecentRunResponse), + RunFinished(TaskResponse), +} + +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, +) -> 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 new file mode 100644 index 0000000..cc38e19 --- /dev/null +++ b/src/server/notifications/push_handlers.rs @@ -0,0 +1,133 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::entities::{push_subscription, task_subscription}; +use crate::error::{AppError, AppResult}; +use crate::server::AppState; +use crate::server::auth::AuthenticatedUser; +use base64::Engine; + +#[derive(serde::Deserialize)] +pub struct RegisterPushRequest { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +pub async fn register_push( + State(state): State>, + 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 index 9782b9c..fddc842 100644 --- a/src/server/tasks.rs +++ b/src/server/tasks.rs @@ -84,7 +84,13 @@ pub async fn create_task( let _ = state.scheduler.add_task_job(task_id, cron).await; } - tasks::get_task_inner(task_id, &state.db).await.map(Json) + let task_response = tasks::get_task_inner(task_id, &state.db).await?; + let _ = state + .tx + .send(crate::server::notifications::WsEvent::TaskCreated( + task_response.clone(), + )); + Ok(Json(task_response)) } pub async fn rerun_task( @@ -103,6 +109,7 @@ pub async fn rerun_task( &state.db, &state.scheduler, &state.config, + state.calendar_client.clone(), task.id, task.goal, ) @@ -150,7 +157,13 @@ pub async fn update_task( let _ = state.scheduler.remove_task_job(id).await; } - tasks::get_task_inner(id, &state.db).await.map(Json) + let task_response = tasks::get_task_inner(id, &state.db).await?; + let _ = state + .tx + .send(crate::server::notifications::WsEvent::TaskUpdated( + task_response.clone(), + )); + Ok(Json(task_response)) } pub async fn get_task(