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 @@
-
-
-
-
- | Directive |
- Status |
- Date |
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Establish connection with the neural assistant.
+
+
+
+
+
+
+
+
+
+
+
+ | Directive |
+ Status |
+ Date |
+
+
+
+
+
+
+
+
@@ -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