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/Cargo.lock b/Cargo.lock
index 8030dcb..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,10 +178,10 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -199,7 +216,7 @@ dependencies = [
"async-trait",
"bytes",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"mime",
@@ -221,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",
@@ -242,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"
@@ -268,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"
@@ -327,11 +368,12 @@ version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
- "base64",
+ "base64 0.22.1",
"chrono",
"cookie",
"dashmap",
"dotenvy",
+ "isahc",
"jsonwebtoken",
"migration",
"reqwest",
@@ -346,6 +388,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
+ "web-push",
]
[[package]]
@@ -388,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"
@@ -474,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"
@@ -489,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"
@@ -594,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"
@@ -676,17 +779,50 @@ 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"
@@ -757,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",
]
@@ -785,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]]
@@ -799,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]]
@@ -839,8 +1003,8 @@ dependencies = [
"generic-array",
"group",
"hkdf",
- "pem-rfc7468",
- "pkcs8",
+ "pem-rfc7468 0.7.0",
+ "pkcs8 0.10.2",
"rand_core",
"sec1",
"subtle",
@@ -883,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"
@@ -894,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"
@@ -1033,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"
@@ -1090,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]]
@@ -1123,7 +1332,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.0",
"indexmap",
"slab",
"tokio",
@@ -1178,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",
@@ -1193,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]]
@@ -1232,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"
@@ -1241,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"
@@ -1258,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1269,7 +1513,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.4.0",
"http-body",
"pin-project-lite",
]
@@ -1297,7 +1541,7 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
- "http",
+ "http 1.4.0",
"http-body",
"httparse",
"httpdate",
@@ -1315,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",
@@ -1347,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",
@@ -1519,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"
@@ -1541,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"
@@ -1563,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"
@@ -1601,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",
]
@@ -1622,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"
@@ -1713,7 +2055,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
- "http",
+ "http 1.4.0",
"httparse",
"memchr",
"mime",
@@ -1744,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]]
@@ -1838,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",
@@ -1962,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"
@@ -2014,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"
@@ -2026,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]]
@@ -2043,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]]
@@ -2053,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"
@@ -2216,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]]
@@ -2225,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]]
@@ -2272,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",
@@ -2359,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",
]
@@ -2410,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",
@@ -2650,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",
@@ -2799,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"
@@ -2839,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"
@@ -2867,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"
@@ -2874,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.10",
]
[[package]]
@@ -2896,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",
@@ -2976,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",
@@ -3001,7 +3484,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"rand",
- "rsa",
+ "rsa 0.9.10",
"rust_decimal",
"serde",
"sha1",
@@ -3023,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",
@@ -3179,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"
@@ -3196,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",
]
@@ -3223,7 +3718,7 @@ 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",
@@ -3487,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",
@@ -3503,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",
@@ -3560,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"
@@ -3604,7 +4109,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
- "http",
+ "http 1.4.0",
"httparse",
"log",
"rand",
@@ -3718,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"
@@ -3748,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"
@@ -3807,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"
@@ -3933,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"
@@ -4193,7 +4744,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
- "synstructure",
+ "synstructure 0.13.2",
]
[[package]]
@@ -4234,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 e7a13dc..9878cd9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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 907ddcd..edcefbf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,8 +4,12 @@
+
+
+
+
+
Antigravity Agent Dashboard
-
↻ Re-run Task
+
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 edf3959..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
@@ -18,7 +19,8 @@ const state = {
isEditing: false,
isAuthenticated: false,
chatMessages: [],
- activeDashboardTab: 'chat' // 'chat' or 'activity'
+ activeDashboardTab: 'chat', // 'chat' or 'activity'
+ swRegistration: null
};
// DOM elements
const loginOverlay = document.getElementById('login-overlay');
@@ -157,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) {
@@ -177,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);
}
@@ -244,6 +262,19 @@ function selectTask(id, runId = null) {
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();
@@ -728,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;
@@ -784,6 +822,16 @@ async function initializeApp() {
if (hasSession) {
appEl.classList.remove('hidden');
loginOverlay.classList.add('hidden');
+
+ // Handle deep links from notifications
+ const params = new URLSearchParams(window.location.search);
+ const taskId = params.get('taskId');
+ const runId = params.get('runId');
+ if (taskId) {
+ state.selectedTaskId = taskId;
+ state.selectedRunId = runId;
+ }
+
await fetchTasks();
connectWebSocket();
} else {
@@ -797,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 be8c314..c2c08b6 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1101,6 +1101,37 @@ textarea:focus {
}
/* 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;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index a71c6f4..a06ef36 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -4,7 +4,7 @@ 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 f886c34..cbd93a8 100644
--- a/src/domain/agent/api.rs
+++ b/src/domain/agent/api.rs
@@ -75,6 +75,8 @@ pub async fn perform_search(
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")
diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs
index 1b6dde8..8f738cc 100644
--- a/src/domain/agent/mod.rs
+++ b/src/domain/agent/mod.rs
@@ -3,6 +3,7 @@ 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};
@@ -13,6 +14,8 @@ pub struct Agent {
url: String,
zen_api_key: Option,
tavily_api_key: Option,
+ calendar_client: Arc,
+ pub user_sub: Option,
pub messages: Vec,
tools: Option>,
logs: String,
@@ -26,6 +29,8 @@ impl Agent {
db: DatabaseConnection,
zen_api_key: Option,
tavily_api_key: Option,
+ calendar_client: Arc,
+ user_sub: Option,
initial_message: String,
) -> AppResult {
let intro = format!(
@@ -50,19 +55,31 @@ impl Agent {
},
];
- Self::with_messages(db, zen_api_key, tavily_api_key, messages)
+ Self::with_messages(
+ db,
+ zen_api_key,
+ tavily_api_key,
+ calendar_client,
+ user_sub,
+ messages,
+ )
}
pub fn with_messages(
db: DatabaseConnection,
zen_api_key: Option,
tavily_api_key: Option,
+ calendar_client: Arc,
+ user_sub: Option,
messages: Vec,
) -> AppResult {
let tools = Some(tools::get_tools());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
+ .connect_timeout(std::time::Duration::from_secs(10))
+ .tcp_keepalive(std::time::Duration::from_secs(30))
+ .pool_idle_timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
@@ -72,6 +89,8 @@ impl Agent {
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(),
@@ -103,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
@@ -114,17 +135,39 @@ impl Agent {
turns, current_role
));
- let assistant_message = self.execute_turn().await?;
+ 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 ---");
@@ -132,10 +175,12 @@ impl Agent {
}
pub async fn execute_turn(&mut self) -> AppResult {
- let max_sub_turns = 10;
+ 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(
@@ -151,27 +196,34 @@ 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 {
+ tracing::info!("Assistant tool calls: {:#?}", tool_calls);
let mut is_final_cycle = false;
let mut final_answer = None;
for tool_call in tool_calls {
self.log(&format!("Calling tool: {}", tool_call.function.name));
- let (tool_message, is_final, tool_answer) =
- tools::handle_tool_call(tool_call, &self.tavily_api_key, &self.db)
- .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.clone());
@@ -190,6 +242,7 @@ impl Agent {
}
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),
@@ -212,7 +265,11 @@ 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));
@@ -222,10 +279,12 @@ impl Agent {
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: {}): {:?}",
+ "Network error after {:?} during LLM call (Timeout: {}, Connect: {}): {:?}",
duration,
is_timeout,
+ is_connect,
e
);
AppError::Network(e)
diff --git a/src/domain/agent/tools.rs b/src/domain/agent/tools.rs
index bbdff4c..2b257c0 100644
--- a/src/domain/agent/tools.rs
+++ b/src/domain/agent/tools.rs
@@ -3,6 +3,7 @@ use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
};
use serde::Deserialize;
+use std::sync::Arc;
use uuid::Uuid;
#[derive(Deserialize)]
@@ -96,6 +97,47 @@ pub fn get_tools() -> Vec {
}),
},
},
+ 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"]
+ }),
+ },
+ },
]
}
@@ -103,6 +145,8 @@ 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;
@@ -198,6 +242,34 @@ pub async fn handle_tool_call(
));
}
(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