From a1dbb328e0dd66dd4696453b51cf0e8182af6ad7 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 00:50:18 +0100 Subject: [PATCH 01/15] pwa --- frontend/apple-touch-icon.png | Bin 0 -> 30607 bytes frontend/icon-192.png | Bin 0 -> 33797 bytes frontend/icon-512.png | Bin 0 -> 27853 bytes frontend/index.html | 5 +++++ frontend/manifest.json | 21 +++++++++++++++++++++ frontend/src/main.js | 9 +++++++++ frontend/sw.js | 26 ++++++++++++++++++++++++++ 7 files changed, 61 insertions(+) create mode 100644 frontend/apple-touch-icon.png create mode 100644 frontend/icon-192.png create mode 100644 frontend/icon-512.png create mode 100644 frontend/manifest.json create mode 100644 frontend/sw.js diff --git a/frontend/apple-touch-icon.png b/frontend/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a5ea0e48d7a22d6e20abc65c71f6e27b39bfd423 GIT binary patch literal 30607 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 bE{3)a|3BuBa{=)`449Odyl9QELE!%YR@2sN literal 0 HcmV?d00001 diff --git a/frontend/icon-192.png b/frontend/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b23b3c7b9af012870660f0e4e8e9c1f77012ed5d GIT binary patch literal 33797 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 literal 0 HcmV?d00001 diff --git a/frontend/index.html b/frontend/index.html index 907ddcd..85feebd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,11 @@ + + + + + Antigravity Agent Dashboard diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..5978a0c --- /dev/null +++ b/frontend/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/src/main.js b/frontend/src/main.js index edf3959..53bcf50 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -797,3 +797,12 @@ 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)) + .catch(err => console.error('SW registration failed', err)); + }); +} diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..c58b0f7 --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,26 @@ +const CACHE_NAME = 'agency-cache-v1'; +const ASSETS = [ + '/', + '/index.html', + '/src/main.js', + '/src/style.css', + '/manifest.json', + '/icon-192.png', + '/icon-512.png' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS); + }) + ); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request); + }) + ); +}); From cc8fcd682b46c2642da7b8d68a1e8fec28b41de4 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 00:53:11 +0100 Subject: [PATCH 02/15] build cache --- .forgejo/workflows/pipeline.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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 From 91db5861c8d8dd02367bd3236538b08eaec57eb3 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 01:28:24 +0100 Subject: [PATCH 03/15] push notifications --- .gitignore | 3 +- Cargo.lock | 691 ++++++++++++++++-- Cargo.toml | 2 + frontend/index.html | 4 +- frontend/src/main.js | 77 +- frontend/src/style.css | 31 + frontend/sw.js | 25 + migration/src/lib.rs | 2 + .../src/m20260212_000005_notifications.rs | 115 +++ src/config.rs | 9 + src/domain/mod.rs | 1 + src/domain/notifications/mod.rs | 1 + src/domain/notifications/push.rs | 101 +++ src/domain/tasks.rs | 37 +- src/entities/mod.rs | 2 + src/entities/push_subscription.rs | 19 + src/entities/task_subscription.rs | 32 + src/scheduler.rs | 50 +- src/server/mod.rs | 10 +- src/server/notifications.rs | 2 + src/server/notifications/push_handlers.rs | 128 ++++ 21 files changed, 1263 insertions(+), 79 deletions(-) create mode 100644 migration/src/m20260212_000005_notifications.rs create mode 100644 src/domain/notifications/mod.rs create mode 100644 src/domain/notifications/push.rs create mode 100644 src/entities/push_subscription.rs create mode 100644 src/entities/task_subscription.rs create mode 100644 src/server/notifications/push_handlers.rs 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 85feebd..edcefbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,6 @@ Antigravity Agent Dashboard - ↻ Re-run Task + diff --git a/frontend/src/main.js b/frontend/src/main.js index 53bcf50..9c2dedf 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 @@ -244,6 +245,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(); @@ -802,7 +816,68 @@ if (window.location.pathname === '/callback' || window.location.search.includes( if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') - .then(reg => console.log('SW registered', reg)) + .then(reg => { + console.log('SW registered', reg); + setupPush(reg); + }) .catch(err => console.error('SW registration failed', err)); }); } + +async function setupPush(registration) { + try { + const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`); + const { publicKey } = await vapidResponse.json(); + + const subscription = await registration.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'); + } catch (err) { + console.warn('Push registration failed:', err); + } +} + +function b64(buffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); +} + +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'; + + 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/sw.js b/frontend/sw.js index c58b0f7..c5efb91 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -24,3 +24,28 @@ self.addEventListener('fetch', (event) => { }) ); }); +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : { title: 'Notification', body: 'New update from Agency' }; + + const options = { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: '1' + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + clients.openWindow('/') + ); +}); 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/src/config.rs b/src/config.rs index c7a714e..5a1d6d6 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 vapid_public_key: String, } impl Config { @@ -54,6 +56,11 @@ 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 vapid_public_key = env::var("VAPID_PUBLIC_KEY") + .map_err(|_| AppError::Config("VAPID_PUBLIC_KEY must be set".into()))?; + Ok(Config { database_url, port, @@ -66,6 +73,8 @@ impl Config { cookie_secure, agent_max_turns, agent_max_duration_secs, + vapid_private_key, + vapid_public_key, }) } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 7982060..70e887f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,4 @@ pub mod agent; pub mod auth; +pub mod notifications; pub mod tasks; diff --git a/src/domain/notifications/mod.rs b/src/domain/notifications/mod.rs new file mode 100644 index 0000000..ec19098 --- /dev/null +++ b/src/domain/notifications/mod.rs @@ -0,0 +1 @@ +pub mod push; diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs new file mode 100644 index 0000000..5aaa54a --- /dev/null +++ b/src/domain/notifications/push.rs @@ -0,0 +1,101 @@ +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; +use web_push::*; + +pub struct PushSender { + pub private_key: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PushSubscription { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +fn ensure_pem(input: &str) -> String { + let input = input.trim(); + if input.contains("-----BEGIN") { + return input.to_string(); + } + + if input.starts_with("MHc") { + format!( + "-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----", + input + ) + } else { + format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----", + input + ) + } +} + +impl PushSender { + pub fn new(private_key_pem: &str) -> AppResult { + let pem = ensure_pem(private_key_pem); + // Validate key immediately to catch config errors early + let _ = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&pem)).map_err(|e| { + crate::error::AppError::Internal(format!("Invalid VAPID private key: {}", e)) + })?; + + Ok(Self { private_key: pem }) + } + + pub fn get_public_key(&self) -> AppResult> { + let builder = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + Ok(builder.get_public_key()) + } + + pub async fn send_notification( + &self, + subscription: &PushSubscription, + title: &str, + body: &str, + ) -> AppResult<()> { + let subscription_info = SubscriptionInfo::new( + subscription.endpoint.clone(), + subscription.p256dh.clone(), + subscription.auth.clone(), + ); + + let builder = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let vapid_signature = builder + .add_sub_info(&subscription_info) + .build() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let mut builder = WebPushMessageBuilder::new(&subscription_info); + + builder.set_vapid_signature(vapid_signature); + + let payload = serde_json::to_vec(&serde_json::json!({ + "title": title, + "body": body, + })) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + builder.set_payload(ContentEncoding::Aes128Gcm, &payload); + + let message = builder + .build() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let client = IsahcWebPushClient::new() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + client + .send(message) + .await + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + Ok(()) + } +} diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 6e7962d..65d3090 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -8,6 +8,7 @@ use crate::config::Config; use crate::domain::agent::Agent; use crate::entities::task::Entity as Task; use crate::entities::task_run::{self, Entity as TaskRun}; +use crate::entities::{push_subscription, task_subscription}; use crate::scheduler::Scheduler; #[derive(Deserialize)] @@ -119,7 +120,7 @@ pub async fn execute_agent_run( let mut run = run; run.logs = Set(logs.clone()); run.answer = Set(answer.clone()); - run.status = Set(status); + run.status = Set(status.clone()); run.update(db) .await @@ -132,6 +133,38 @@ pub async fn execute_agent_run( task_response.clone(), )); + // Send Push Notifications to subscribers + let subscriptions = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id)) + .all(db) + .await + .map_err(crate::error::AppError::Database)?; + + for sub in subscriptions { + let push_subs = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .all(db) + .await + .map_err(crate::error::AppError::Database)?; + + for push_sub in push_subs { + let sender = _scheduler.push_sender.clone(); + let goal = task_response.goal.clone(); + let status = status.clone(); + let sub_data = crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; + + tokio::spawn(async move { + let _ = sender + .send_notification(&sub_data, &format!("Task Completed: {}", status), &goal) + .await; + }); + } + } + Ok(task_response) } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 7ff42bf..304ddb0 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,2 +1,4 @@ +pub mod push_subscription; pub mod task; pub mod task_run; +pub mod task_subscription; diff --git a/src/entities/push_subscription.rs b/src/entities/push_subscription.rs new file mode 100644 index 0000000..c306114 --- /dev/null +++ b/src/entities/push_subscription.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "push_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub endpoint: String, + pub p256dh: String, + pub auth: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/task_subscription.rs b/src/entities/task_subscription.rs new file mode 100644 index 0000000..b7011ee --- /dev/null +++ b/src/entities/task_subscription.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "task_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub task_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::task::Entity", + from = "Column::TaskId", + to = "super::task::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Task, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Task.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/scheduler.rs b/src/scheduler.rs index 9aad91b..3b5b0a8 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -14,6 +14,7 @@ pub struct Scheduler { tasks_to_jobs: DashMap, config: Arc, pub tx: tokio::sync::broadcast::Sender, + pub push_sender: Arc, } impl Scheduler { @@ -21,6 +22,7 @@ impl Scheduler { db: DatabaseConnection, config: Arc, tx: tokio::sync::broadcast::Sender, + push_sender: Arc, ) -> AppResult { let scheduler = JobScheduler::new() .await @@ -35,6 +37,7 @@ impl Scheduler { tasks_to_jobs: DashMap::new(), config, tx, + push_sender, }) } @@ -47,13 +50,15 @@ impl Scheduler { let db = self.db.clone(); let config = self.config.clone(); let tx = self.tx.clone(); + let push_sender = self.push_sender.clone(); let job = Job::new_async(cron_expr, move |_uuid, _l| { let db = db.clone(); let config = config.clone(); let tx = tx.clone(); + let push_sender = push_sender.clone(); Box::pin(async move { - if let Err(e) = Self::run_task(db, config, tx, task_id).await { + if let Err(e) = Self::run_task(db, config, tx, push_sender, task_id).await { tracing::error!("Error in scheduled task {}: {}", task_id, e); } }) @@ -87,6 +92,7 @@ impl Scheduler { db: DatabaseConnection, config: Arc, tx: tokio::sync::broadcast::Sender, + push_sender: Arc, task_id: Uuid, ) -> AppResult<()> { let task = Task::find_by_id(task_id) @@ -147,7 +153,7 @@ impl Scheduler { let run_complete = task_run::ActiveModel { id: Set(run_id), - status: Set(status), + status: Set(status.clone()), logs: Set(logs), answer: Set(answer), ..Default::default() @@ -162,8 +168,46 @@ impl Scheduler { if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await { let _ = tx.send(crate::server::notifications::WsEvent::RunFinished( - task_response, + task_response.clone(), )); + + // Push notifications for scheduled runs + use crate::entities::{push_subscription, task_subscription}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + if let Ok(subscriptions) = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id)) + .all(&db) + .await + { + for sub in subscriptions { + if let Ok(push_subs) = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .all(&db) + .await + { + for push_sub in push_subs { + let sender = push_sender.clone(); + let sub_data = + crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; + let goal = task_response.goal.clone(); + let status = status.clone(); + tokio::spawn(async move { + let _ = sender + .send_notification( + &sub_data, + &format!("Scheduled Task Completed: {}", status), + &goal, + ) + .await; + }); + } + } + } + } } }); diff --git a/src/server/mod.rs b/src/server/mod.rs index 6979d6e..6a9d998 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -35,8 +35,12 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { let (tx, _) = tokio::sync::broadcast::channel(100); + let push_sender = Arc::new(crate::domain::notifications::push::PushSender::new( + &config.vapid_private_key.clone(), + )?); + let scheduler = Arc::new( - Scheduler::new(db.clone(), config.clone(), tx.clone()) + Scheduler::new(db.clone(), config.clone(), tx.clone(), push_sender.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, ); @@ -128,6 +132,10 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .route("/api/auth/logout", post(auth::auth_logout)) .route("/api/chat", post(chat::chat_handler)) .route("/api/ws", get(notifications::ws_handler)) + .route("/api/notifications/register", post(notifications::push_handlers::register_push)) + .route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key)) + .route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status)) + .route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task)) .layer(cors) .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( axum::http::header::CONTENT_SECURITY_POLICY, diff --git a/src/server/notifications.rs b/src/server/notifications.rs index dcc62a3..0786d0f 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -1,3 +1,5 @@ +pub mod push_handlers; + use crate::domain::tasks::{RecentRunResponse, TaskResponse}; use crate::server::AppState; use axum::{ diff --git a/src/server/notifications/push_handlers.rs b/src/server/notifications/push_handlers.rs new file mode 100644 index 0000000..00860c4 --- /dev/null +++ b/src/server/notifications/push_handlers.rs @@ -0,0 +1,128 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::entities::{push_subscription, task_subscription}; +use crate::error::{AppError, AppResult}; +use crate::server::AppState; +use crate::server::auth::AuthenticatedUser; +use base64::Engine; + +#[derive(serde::Deserialize)] +pub struct RegisterPushRequest { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +pub async fn register_push( + State(state): State>, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + let user_sub = user.0.sub; + + // Check if subscription exists + let existing = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(user_sub.clone())) + .filter(push_subscription::Column::Endpoint.eq(payload.endpoint.clone())) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + if existing.is_none() { + let new_sub = push_subscription::ActiveModel { + id: Set(Uuid::new_v4()), + user_sub: Set(user_sub), + endpoint: Set(payload.endpoint), + p256dh: Set(payload.p256dh), + auth: Set(payload.auth), + created_at: Set(Utc::now().into()), + }; + new_sub + .insert(&state.db) + .await + .map_err(AppError::Database)?; + } + + Ok(Json(serde_json::json!({ "status": "registered" }))) +} + +pub async fn subscribe_task( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + let existing = task_subscription::Entity::find() + .filter(task_subscription::Column::UserSub.eq(user_sub.clone())) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + if existing.is_none() { + let new_sub = task_subscription::ActiveModel { + id: Set(Uuid::new_v4()), + user_sub: Set(user_sub), + task_id: Set(task_id), + created_at: Set(Utc::now().into()), + }; + new_sub + .insert(&state.db) + .await + .map_err(AppError::Database)?; + } + + Ok(Json(serde_json::json!({ "status": "subscribed" }))) +} + +pub async fn unsubscribe_task( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + task_subscription::Entity::delete_many() + .filter(task_subscription::Column::UserSub.eq(user_sub)) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .exec(&state.db) + .await + .map_err(AppError::Database)?; + + Ok(Json(serde_json::json!({ "status": "unsubscribed" }))) +} + +pub async fn get_vapid_key( + State(state): State>, +) -> AppResult> { + let public_key = state.scheduler.push_sender.get_public_key()?; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key); + Ok(Json(serde_json::json!({ "publicKey": encoded }))) +} + +pub async fn get_subscription_status( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + let existing = task_subscription::Entity::find() + .filter(task_subscription::Column::UserSub.eq(user_sub)) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + Ok(Json( + serde_json::json!({ "isSubscribed": existing.is_some() }), + )) +} From 8d1987373890b92e4910f54ed1da0c7229383808 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 01:34:24 +0100 Subject: [PATCH 04/15] push --- frontend/src/main.js | 16 ++++++++++++++-- src/config.rs | 4 ---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/main.js b/frontend/src/main.js index 9c2dedf..1af24b6 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -742,7 +742,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; @@ -820,7 +827,12 @@ if ('serviceWorker' in navigator) { console.log('SW registered', reg); setupPush(reg); }) - .catch(err => console.error('SW registration failed', err)); + .catch(err => { + console.error('SW registration failed:', err); + if (window.isSecureContext === false) { + console.error('Context is NOT secure. Service Workers require HTTPS or localhost.'); + } + }); }); } diff --git a/src/config.rs b/src/config.rs index 5a1d6d6..b69a4c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,7 +15,6 @@ pub struct Config { pub agent_max_turns: u32, pub agent_max_duration_secs: u64, pub vapid_private_key: String, - pub vapid_public_key: String, } impl Config { @@ -58,8 +57,6 @@ impl Config { let vapid_private_key = env::var("VAPID_PRIVATE_KEY") .map_err(|_| AppError::Config("VAPID_PRIVATE_KEY must be set".into()))?; - let vapid_public_key = env::var("VAPID_PUBLIC_KEY") - .map_err(|_| AppError::Config("VAPID_PUBLIC_KEY must be set".into()))?; Ok(Config { database_url, @@ -74,7 +71,6 @@ impl Config { agent_max_turns, agent_max_duration_secs, vapid_private_key, - vapid_public_key, }) } } From e7d1dbe84915fee9d63f91a8e6ba990c167b45d3 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 01:45:26 +0100 Subject: [PATCH 05/15] pushh --- frontend/{ => public}/apple-touch-icon.png | Bin frontend/{ => public}/icon-192.png | Bin frontend/{ => public}/icon-512.png | Bin frontend/{ => public}/manifest.json | 0 frontend/{ => public}/sw.js | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename frontend/{ => public}/apple-touch-icon.png (100%) rename frontend/{ => public}/icon-192.png (100%) rename frontend/{ => public}/icon-512.png (100%) rename frontend/{ => public}/manifest.json (100%) rename frontend/{ => public}/sw.js (100%) diff --git a/frontend/apple-touch-icon.png b/frontend/public/apple-touch-icon.png similarity index 100% rename from frontend/apple-touch-icon.png rename to frontend/public/apple-touch-icon.png diff --git a/frontend/icon-192.png b/frontend/public/icon-192.png similarity index 100% rename from frontend/icon-192.png rename to frontend/public/icon-192.png diff --git a/frontend/icon-512.png b/frontend/public/icon-512.png similarity index 100% rename from frontend/icon-512.png rename to frontend/public/icon-512.png diff --git a/frontend/manifest.json b/frontend/public/manifest.json similarity index 100% rename from frontend/manifest.json rename to frontend/public/manifest.json diff --git a/frontend/sw.js b/frontend/public/sw.js similarity index 100% rename from frontend/sw.js rename to frontend/public/sw.js From 37986f8b0b6ebc603129761bf04b0f9967114137 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 01:51:39 +0100 Subject: [PATCH 06/15] puush --- frontend/src/main.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/src/main.js b/frontend/src/main.js index 1af24b6..8fe9419 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -19,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'); @@ -825,7 +826,7 @@ if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') .then(reg => { console.log('SW registered', reg); - setupPush(reg); + state.swRegistration = reg; }) .catch(err => { console.error('SW registration failed:', err); @@ -836,12 +837,17 @@ if ('serviceWorker' in navigator) { }); } -async function setupPush(registration) { +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(); - const subscription = await registration.pushManager.subscribe({ + const subscription = await state.swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) }); @@ -856,8 +862,10 @@ async function setupPush(registration) { }) }); console.log('Push registered'); + return true; } catch (err) { console.warn('Push registration failed:', err); + return false; } } @@ -881,6 +889,26 @@ async function toggleTaskSubscription(taskId) { 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'); From 6f6827d4d93ff4404a4255007ef6d3aa111309b1 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 02:02:51 +0100 Subject: [PATCH 07/15] a --- src/domain/notifications/push.rs | 13 +++++++++---- src/domain/tasks.rs | 14 +++++++++++++- src/server/notifications/push_handlers.rs | 5 +++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs index 5aaa54a..70ebb83 100644 --- a/src/domain/notifications/push.rs +++ b/src/domain/notifications/push.rs @@ -91,10 +91,15 @@ impl PushSender { let client = IsahcWebPushClient::new() .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; - client - .send(message) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + client.send(message).await.map_err(|e| { + tracing::error!("Failed to send push notification: {}", e); + crate::error::AppError::Internal(e.to_string()) + })?; + + tracing::info!( + "Push notification sent successfully to {}", + subscription.endpoint + ); Ok(()) } diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 65d3090..1993535 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -140,13 +140,25 @@ pub async fn execute_agent_run( .await .map_err(crate::error::AppError::Database)?; + tracing::info!( + "Found {} task subscriptions for task {}", + subscriptions.len(), + task_id + ); + for sub in subscriptions { let push_subs = push_subscription::Entity::find() - .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone())) .all(db) .await .map_err(crate::error::AppError::Database)?; + tracing::info!( + "Found {} push subscriptions for user {}", + push_subs.len(), + sub.user_sub + ); + for push_sub in push_subs { let sender = _scheduler.push_sender.clone(); let goal = task_response.goal.clone(); diff --git a/src/server/notifications/push_handlers.rs b/src/server/notifications/push_handlers.rs index 00860c4..cc38e19 100644 --- a/src/server/notifications/push_handlers.rs +++ b/src/server/notifications/push_handlers.rs @@ -26,6 +26,11 @@ pub async fn register_push( Json(payload): Json, ) -> AppResult> { let user_sub = user.0.sub; + tracing::info!( + "Registering push subscription for user: {} with endpoint: {}", + user_sub, + payload.endpoint + ); // Check if subscription exists let existing = push_subscription::Entity::find() From 0e41f57ce5f9e2b0301f85ffce76854804eab5ce Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 02:08:05 +0100 Subject: [PATCH 08/15] a --- src/domain/notifications/push.rs | 4 +++- src/domain/tasks.rs | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs index 70ebb83..505c38f 100644 --- a/src/domain/notifications/push.rs +++ b/src/domain/notifications/push.rs @@ -67,8 +67,10 @@ impl PushSender { VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + let mut builder = builder.add_sub_info(&subscription_info); + builder.add_claim("sub", "mailto:pavel@flegr.me"); + let vapid_signature = builder - .add_sub_info(&subscription_info) .build() .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 1993535..ba69ae7 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -170,9 +170,12 @@ pub async fn execute_agent_run( }; tokio::spawn(async move { - let _ = sender + if let Err(e) = sender .send_notification(&sub_data, &format!("Task Completed: {}", status), &goal) - .await; + .await + { + tracing::error!("Failed to send notification in background task: {}", e); + } }); } } From 0dc8251ec7a4c46bc99af5dc417e8396c8629b47 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 02:12:42 +0100 Subject: [PATCH 09/15] a --- frontend/src/main.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/main.js b/frontend/src/main.js index 8fe9419..2a03a04 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -847,6 +847,13 @@ async function setupPush() { 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) @@ -870,7 +877,11 @@ async function setupPush() { } function b64(buffer) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + const binary = String.fromCharCode.apply(null, new Uint8Array(buffer)); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); } function urlBase64ToUint8Array(base64String) { From 779500d22e172fc336594c76755603ab83883f9c Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 20:53:50 +0100 Subject: [PATCH 10/15] fix site load --- frontend/public/sw.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index c5efb91..c8d352d 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,9 +1,7 @@ -const CACHE_NAME = 'agency-cache-v1'; +const CACHE_NAME = 'agency-cache-v2'; const ASSETS = [ '/', '/index.html', - '/src/main.js', - '/src/style.css', '/manifest.json', '/icon-192.png', '/icon-512.png' @@ -18,9 +16,21 @@ self.addEventListener('install', (event) => { }); self.addEventListener('fetch', (event) => { + // Only intercept http/https requests + if (!event.request.url.startsWith('http')) return; + event.respondWith( caches.match(event.request).then((response) => { - return response || fetch(event.request); + // Return cached response if found, otherwise fetch from network + return response || 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, let the browser handle the failure normally + console.error('Fetch failed:', event.request.url, error); + throw error; + }); }) ); }); From 42a78c529df6f340078fb5351890ee05a7ac7f82 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 21:26:41 +0100 Subject: [PATCH 11/15] notification links --- frontend/public/sw.js | 38 ++++++++++++++++++++++++++++---- frontend/src/main.js | 26 ++++++++++++++++++++++ src/domain/notifications/push.rs | 5 +++++ src/domain/tasks.rs | 8 ++++++- src/scheduler.rs | 2 ++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index c8d352d..5a3179e 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'agency-cache-v2'; +const CACHE_NAME = 'agency-cache-v3'; const ASSETS = [ '/', '/index.html', @@ -34,8 +34,16 @@ self.addEventListener('fetch', (event) => { }) ); }); + self.addEventListener('push', (event) => { - const data = event.data ? event.data.json() : { title: 'Notification', body: 'New update from Agency' }; + 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, @@ -44,7 +52,9 @@ self.addEventListener('push', (event) => { vibrate: [100, 50, 100], data: { dateOfArrival: Date.now(), - primaryKey: '1' + primaryKey: '1', + taskId: data.task_id, + runId: data.run_id } }; @@ -55,7 +65,27 @@ self.addEventListener('push', (event) => { 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.openWindow('/') + 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 2a03a04..ffae909 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -159,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) { @@ -179,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); } @@ -806,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 { diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs index 505c38f..478fe2a 100644 --- a/src/domain/notifications/push.rs +++ b/src/domain/notifications/push.rs @@ -1,5 +1,6 @@ use crate::error::AppResult; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use web_push::*; pub struct PushSender { @@ -56,6 +57,8 @@ impl PushSender { subscription: &PushSubscription, title: &str, body: &str, + task_id: Option, + run_id: Option, ) -> AppResult<()> { let subscription_info = SubscriptionInfo::new( subscription.endpoint.clone(), @@ -81,6 +84,8 @@ impl PushSender { let payload = serde_json::to_vec(&serde_json::json!({ "title": title, "body": body, + "task_id": task_id, + "run_id": run_id, })) .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index ba69ae7..90c2286 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -171,7 +171,13 @@ pub async fn execute_agent_run( tokio::spawn(async move { if let Err(e) = sender - .send_notification(&sub_data, &format!("Task Completed: {}", status), &goal) + .send_notification( + &sub_data, + &format!("Task Completed: {}", status), + &goal, + Some(task_id), + Some(run_id), + ) .await { tracing::error!("Failed to send notification in background task: {}", e); diff --git a/src/scheduler.rs b/src/scheduler.rs index 3b5b0a8..feac531 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -201,6 +201,8 @@ impl Scheduler { &sub_data, &format!("Scheduled Task Completed: {}", status), &goal, + Some(task_id), + Some(run_id), ) .await; }); From d3cdcea0dfb48d04d94cd3fed4f790f54d84f9b1 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 12 Feb 2026 21:50:29 +0100 Subject: [PATCH 12/15] a --- frontend/public/sw.js | 44 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 5a3179e..e976c34 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'agency-cache-v3'; +const CACHE_NAME = 'agency-cache-v4'; const ASSETS = [ '/', '/index.html', @@ -7,11 +7,28 @@ const ASSETS = [ '/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()) ); }); @@ -20,16 +37,27 @@ self.addEventListener('fetch', (event) => { if (!event.request.url.startsWith('http')) return; event.respondWith( - caches.match(event.request).then((response) => { - // Return cached response if found, otherwise fetch from network - return response || fetch(event.request).catch(error => { + 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, let the browser handle the failure normally - console.error('Fetch failed:', event.request.url, error); - throw error; + + // 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' }) + }); }); }) ); From 9aca4cadf03288015363c9d4d3ea1acb26867cc0 Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 17 Feb 2026 19:07:06 +0100 Subject: [PATCH 13/15] stuff --- frontend/vite.config.js | 2 +- src/config.rs | 5 + src/domain/agent/mod.rs | 33 ++++- src/domain/agent/tools.rs | 72 +++++++++ src/domain/auth.rs | 23 +++ src/domain/calendar/mod.rs | 292 +++++++++++++++++++++++++++++++++++++ src/domain/mod.rs | 1 + src/domain/tasks.rs | 3 + src/error.rs | 16 +- src/scheduler.rs | 12 +- src/server/chat.rs | 3 + src/server/mod.rs | 41 +++++- src/server/tasks.rs | 1 + 13 files changed, 483 insertions(+), 21 deletions(-) create mode 100644 src/domain/calendar/mod.rs 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/src/config.rs b/src/config.rs index b69a4c0..b81a727 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub struct Config { pub agent_max_turns: u32, pub agent_max_duration_secs: u64, pub vapid_private_key: String, + pub calendar_api_url: String, } impl Config { @@ -58,6 +59,9 @@ impl Config { 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, @@ -71,6 +75,7 @@ impl Config { agent_max_turns, agent_max_duration_secs, vapid_private_key, + calendar_api_url, }) } } diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs index 1b6dde8..95f1c5e 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,13 +55,22 @@ 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()); @@ -72,6 +86,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(), @@ -166,12 +182,15 @@ impl Agent { 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()); 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..10a1255 100644 --- a/src/domain/auth.rs +++ b/src/domain/auth.rs @@ -190,4 +190,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..d6176d1 --- /dev/null +++ b/src/domain/calendar/mod.rs @@ -0,0 +1,292 @@ +use crate::domain::auth::Authenticator; +use crate::error::{AppError, AppResult}; +use chrono::{DateTime, Duration, Utc}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarEvent { + pub id: Option, + pub name: String, + pub from: DateTime, + pub to: DateTime, + pub user_sub: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateEventRequest { + pub name: String, + pub from: String, + pub to: String, + pub user_sub: Option, +} +struct TokenState { + access_token: String, + expires_at: DateTime, +} + +pub struct CalendarClient { + base_url: String, + client: Client, + authenticator: Arc, + token_state: RwLock>, +} + +impl CalendarClient { + pub fn new(base_url: String, authenticator: Arc) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + client: Client::new(), + authenticator, + token_state: RwLock::new(None), + } + } + + async fn get_token(&self) -> AppResult { + { + let state = self.token_state.read().await; + if let Some(token) = &*state { + if token.expires_at > Utc::now() + Duration::seconds(30) { + tracing::debug!("Using cached Calendar API token"); + return Ok(token.access_token.clone()); + } + } + } + + let mut state = self.token_state.write().await; + // Double check after acquiring write lock + if let Some(token) = &*state { + if token.expires_at > Utc::now() + Duration::seconds(30) { + return Ok(token.access_token.clone()); + } + } + + tracing::info!("Refreshing Calendar API token via Client Credentials flow"); + let token_data = self + .authenticator + .client_credentials("profile") + .await + .map_err(|e| AppError::Internal(format!("Failed to get client credentials: {}", e)))?; + + let access_token = token_data["access_token"] + .as_str() + .ok_or_else(|| AppError::Internal("Missing access_token in response".into()))? + .to_string(); + + let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600); + + let expires_at = Utc::now() + Duration::seconds(expires_in); + + *state = Some(TokenState { + access_token: access_token.clone(), + expires_at, + }); + + Ok(access_token) + } + + pub async fn list_events( + &self, + user_sub: Option, + upcoming: Option, + ) -> AppResult> { + let token = self.get_token().await?; + let mut url = format!("{}/service/v1/events", self.base_url); + let mut params = Vec::new(); + if let Some(uid) = &user_sub { + params.push(format!("user_sub={}", uid)); + } + if let Some(u) = upcoming { + params.push(format!("upcoming={}", u)); + } + + if !params.is_empty() { + url.push_str("?"); + url.push_str(¶ms.join("&")); + } + + tracing::info!(method = "GET", %url, "Sending Calendar API request"); + + let res = self + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(AppError::Network)?; + + let status = res.status(); + tracing::info!(%status, %url, "Received Calendar API response"); + + if !status.is_success() { + let error_body = res.text().await.unwrap_or_default(); + tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); + return Err(AppError::Internal(format!( + "Failed to list events: {} - {}", + status, error_body + ))); + } + + let body = res + .json() + .await + .map_err(|e| AppError::Internal(e.to_string())); + tracing::info!(%status, %url, body = ?body, "Received Calendar API response"); + body + } + + pub async fn create_event( + &self, + user_sub: Option, + name: &str, + from: &str, + to: &str, + ) -> AppResult { + let token = self.get_token().await?; + let url = format!("{}/service/v1/events", self.base_url); + + let request = CreateEventRequest { + name: name.to_string(), + from: from.to_string(), + to: to.to_string(), + user_sub, + }; + + tracing::info!(method = "POST", %url, "Sending Calendar API request"); + + let res = self + .client + .post(&url) + .bearer_auth(token) + .json(&request) + .send() + .await + .map_err(AppError::Network)?; + + let status = res.status(); + tracing::info!(%status, %url, "Received Calendar API response"); + + if !status.is_success() { + let error_body = res.text().await.unwrap_or_default(); + tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); + return Err(AppError::Internal(format!( + "Failed to create event: {} - {}", + status, error_body + ))); + } + + res.json() + .await + .map_err(|e| AppError::Internal(e.to_string())) + } + + #[allow(dead_code)] + pub async fn get_event(&self, id: i32) -> AppResult { + let token = self.get_token().await?; + let url = format!("{}/service/v1/events/{}", self.base_url, id); + tracing::info!(method = "GET", %url, "Sending Calendar API request"); + + let res = self + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(AppError::Network)?; + + let status = res.status(); + tracing::info!(%status, %url, "Received Calendar API response"); + + if !status.is_success() { + let error_body = res.text().await.unwrap_or_default(); + tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); + return Err(AppError::Internal(format!( + "Failed to get event: {} - {}", + status, error_body + ))); + } + + res.json() + .await + .map_err(|e| AppError::Internal(e.to_string())) + } + + #[allow(dead_code)] + pub async fn update_event( + &self, + id: i32, + user_sub: Option, + name: &str, + from: &str, + to: &str, + ) -> AppResult { + let token = self.get_token().await?; + let url = format!("{}/service/v1/events/{}", self.base_url, id); + + let request = CreateEventRequest { + name: name.to_string(), + from: from.to_string(), + to: to.to_string(), + user_sub, + }; + + tracing::info!(method = "PUT", %url, "Sending Calendar API request"); + + let res = self + .client + .put(&url) + .bearer_auth(token) + .json(&request) + .send() + .await + .map_err(AppError::Network)?; + + let status = res.status(); + tracing::info!(%status, %url, "Received Calendar API response"); + + if !status.is_success() { + let error_body = res.text().await.unwrap_or_default(); + tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); + return Err(AppError::Internal(format!( + "Failed to update event: {} - {}", + status, error_body + ))); + } + + res.json() + .await + .map_err(|e| AppError::Internal(e.to_string())) + } + + #[allow(dead_code)] + pub async fn delete_event(&self, id: i32) -> AppResult<()> { + let token = self.get_token().await?; + let url = format!("{}/service/v1/events/{}", self.base_url, id); + + tracing::info!(method = "DELETE", %url, "Sending Calendar API request"); + + let res = self + .client + .delete(&url) + .bearer_auth(token) + .send() + .await + .map_err(AppError::Network)?; + + let status = res.status(); + tracing::info!(%status, %url, "Received Calendar API response"); + + if !status.is_success() { + let error_body = res.text().await.unwrap_or_default(); + tracing::error!(%status, %url, body = %error_body, "Calendar API request failed"); + return Err(AppError::Internal(format!( + "Failed to delete event: {} - {}", + status, error_body + ))); + } + + Ok(()) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 70e887f..4a21816 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,4 +1,5 @@ pub mod agent; pub mod auth; +pub mod calendar; pub mod notifications; pub mod tasks; diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 90c2286..2ea8b26 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -56,6 +56,7 @@ pub async fn execute_agent_run( db: &DatabaseConnection, _scheduler: &Arc, config: &Arc, + calendar_client: Arc, task_id: Uuid, goal: String, ) -> AppResult { @@ -92,6 +93,8 @@ pub async fn execute_agent_run( db.clone(), config.zen_api_key.clone(), config.tavily_api_key.clone(), + calendar_client.clone(), + None, goal.clone(), )?; diff --git a/src/error.rs b/src/error.rs index 01943d5..81194ce 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,16 +32,20 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, error_message) = match self { + let (status, error_message) = match &self { AppError::Database(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), - AppError::NotFound(err) => (StatusCode::NOT_FOUND, err), - AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err), - AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), + AppError::Config(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()), + AppError::NotFound(err) => (StatusCode::NOT_FOUND, err.clone()), + AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err.clone()), + AppError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.clone()), AppError::Network(err) => (StatusCode::BAD_GATEWAY, err.to_string()), - AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err), + AppError::InvalidRequest(err) => (StatusCode::BAD_REQUEST, err.clone()), }; + if status.is_server_error() || status.is_client_error() { + tracing::error!(%status, error = %self, "AppError converted to response"); + } + let body = Json(json!({ "error": error_message, })); diff --git a/src/scheduler.rs b/src/scheduler.rs index feac531..c98debd 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -13,6 +13,7 @@ pub struct Scheduler { db: DatabaseConnection, tasks_to_jobs: DashMap, config: Arc, + pub calendar_client: Arc, pub tx: tokio::sync::broadcast::Sender, pub push_sender: Arc, } @@ -21,6 +22,7 @@ impl Scheduler { pub async fn new( db: DatabaseConnection, config: Arc, + calendar_client: Arc, tx: tokio::sync::broadcast::Sender, push_sender: Arc, ) -> AppResult { @@ -36,6 +38,7 @@ impl Scheduler { db, tasks_to_jobs: DashMap::new(), config, + calendar_client, tx, push_sender, }) @@ -52,13 +55,17 @@ impl Scheduler { let tx = self.tx.clone(); let push_sender = self.push_sender.clone(); + let calendar_client = self.calendar_client.clone(); let job = Job::new_async(cron_expr, move |_uuid, _l| { let db = db.clone(); let config = config.clone(); let tx = tx.clone(); let push_sender = push_sender.clone(); + let calendar_client = calendar_client.clone(); Box::pin(async move { - if let Err(e) = Self::run_task(db, config, tx, push_sender, task_id).await { + if let Err(e) = + Self::run_task(db, config, calendar_client, tx, push_sender, task_id).await + { tracing::error!("Error in scheduled task {}: {}", task_id, e); } }) @@ -91,6 +98,7 @@ impl Scheduler { async fn run_task( db: DatabaseConnection, config: Arc, + calendar_client: Arc, tx: tokio::sync::broadcast::Sender, push_sender: Arc, task_id: Uuid, @@ -132,6 +140,8 @@ impl Scheduler { db.clone(), config.zen_api_key.clone(), config.tavily_api_key.clone(), + calendar_client.clone(), + None, task.goal.clone(), )?; diff --git a/src/server/chat.rs b/src/server/chat.rs index 5163275..d6b6bcd 100644 --- a/src/server/chat.rs +++ b/src/server/chat.rs @@ -17,6 +17,7 @@ pub struct ChatResult { pub async fn chat_handler( State(state): State>, + user: crate::server::auth::AuthenticatedUser, Json(payload): Json, ) -> Result, AppError> { let msg_count = payload.messages.len(); @@ -45,6 +46,8 @@ pub async fn chat_handler( state.db.clone(), state.config.zen_api_key.clone(), state.config.tavily_api_key.clone(), + state.calendar_client.clone(), + Some(user.0.sub), messages, )?; diff --git a/src/server/mod.rs b/src/server/mod.rs index 6a9d998..5160b4e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -13,7 +13,6 @@ use sea_orm::{Database, DatabaseConnection, EntityTrait}; use std::sync::Arc; use tower_http::cors::{AllowOrigin, CorsLayer}; -use crate::entities::task::Entity as Task; use crate::scheduler::Scheduler; use crate::error::AppResult; @@ -25,6 +24,7 @@ pub struct AppState { pub config: Arc, pub verifier: Arc, pub authenticator: Arc, + pub calendar_client: Arc, pub tx: tokio::sync::broadcast::Sender, } @@ -39,13 +39,26 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { &config.vapid_private_key.clone(), )?); + let (verifier, authenticator) = setup_auth(&config).await?; + let calendar_client = Arc::new(crate::domain::calendar::CalendarClient::new( + config.calendar_api_url.clone(), + authenticator.clone(), + )); + let scheduler = Arc::new( - Scheduler::new(db.clone(), config.clone(), tx.clone(), push_sender.clone()) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, + Scheduler::new( + db.clone(), + config.clone(), + calendar_client.clone(), + tx.clone(), + push_sender.clone(), + ) + .await + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, ); // Load existing scheduled tasks + use crate::entities::task::Entity as Task; let existing_tasks = Task::find() .all(&db) .await @@ -56,14 +69,13 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { } } - let (verifier, authenticator) = setup_auth(&config).await?; - let state = Arc::new(AppState { db, scheduler, config: config.clone(), verifier, authenticator, + calendar_client, tx, }); @@ -136,6 +148,7 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key)) .route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status)) .route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task)) + .layer(axum::middleware::from_fn(log_error_responses)) .layer(cors) .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( axum::http::header::CONTENT_SECURITY_POLICY, @@ -153,6 +166,22 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .with_state(state) } +async fn log_error_responses( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let method = req.method().clone(); + let uri = req.uri().clone(); + let res = next.run(req).await; + let status = res.status(); + + if status.is_client_error() || status.is_server_error() { + tracing::error!(%method, %uri, %status, "Response error"); + } + + res +} + fn build_cors_layer(config: &crate::config::Config) -> CorsLayer { let allow_origin = if let Some(origins) = &config.cors_allowed_origins { let values: Vec = origins diff --git a/src/server/tasks.rs b/src/server/tasks.rs index 5c5da2b..fddc842 100644 --- a/src/server/tasks.rs +++ b/src/server/tasks.rs @@ -109,6 +109,7 @@ pub async fn rerun_task( &state.db, &state.scheduler, &state.config, + state.calendar_client.clone(), task.id, task.goal, ) From 7384bcaf4095a7af4124f67fbc3f2a4d0f19d4b9 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 20 Feb 2026 18:00:43 +0100 Subject: [PATCH 14/15] idk --- openapi/calendar/openapi.json | 593 ++++++++++++++++++++++++++++++++++ src/domain/agent/mod.rs | 13 +- 2 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 openapi/calendar/openapi.json 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/domain/agent/mod.rs b/src/domain/agent/mod.rs index 95f1c5e..dd87dba 100644 --- a/src/domain/agent/mod.rs +++ b/src/domain/agent/mod.rs @@ -141,6 +141,17 @@ impl Agent { if self.answer.is_some() { 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, + }); + } } self.log("\n--- Execution Finished ---"); @@ -148,7 +159,7 @@ 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 { From f9e976c224be7a7e6cfb22aa0a318315e9364509 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 20 Feb 2026 18:42:59 +0100 Subject: [PATCH 15/15] a --- src/domain/agent/api.rs | 2 + src/domain/agent/mod.rs | 35 +++++++- src/domain/auth.rs | 10 ++- src/domain/calendar/mod.rs | 8 +- src/domain/tasks.rs | 161 +++++++++++++++++-------------------- src/server/chat.rs | 10 ++- 6 files changed, 133 insertions(+), 93 deletions(-) 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 dd87dba..8f738cc 100644 --- a/src/domain/agent/mod.rs +++ b/src/domain/agent/mod.rs @@ -77,6 +77,9 @@ impl Agent { 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)))?; @@ -119,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 @@ -130,15 +135,24 @@ 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; } @@ -152,6 +166,8 @@ impl Agent { tool_call_id: None, }); } + + tracing::info!("Finished turn"); } self.log("\n--- Execution Finished ---"); @@ -163,6 +179,8 @@ impl Agent { 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( @@ -178,15 +196,19 @@ 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; @@ -220,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), @@ -242,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)); @@ -252,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/auth.rs b/src/domain/auth.rs index 10a1255..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('/') diff --git a/src/domain/calendar/mod.rs b/src/domain/calendar/mod.rs index d6176d1..c6f49ae 100644 --- a/src/domain/calendar/mod.rs +++ b/src/domain/calendar/mod.rs @@ -36,9 +36,15 @@ pub struct CalendarClient { impl CalendarClient { pub fn new(base_url: String, authenticator: Arc) -> Self { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()); + Self { base_url: base_url.trim_end_matches('/').to_string(), - client: Client::new(), + client, authenticator, token_state: RwLock::new(None), } diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 2ea8b26..bd518ef 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -61,7 +61,7 @@ pub async fn execute_agent_run( goal: String, ) -> AppResult { let run_id = Uuid::new_v4(); - tracing::info!(%task_id, %run_id, "Starting agent execution run"); + tracing::info!(%task_id, %run_id, "Starting background agent execution run"); let new_run = task_run::ActiveModel { id: Set(run_id), @@ -98,98 +98,87 @@ pub async fn execute_agent_run( goal.clone(), )?; - let (logs, answer, status) = match agent.run(config).await { - Ok((logs, answer)) => { - tracing::info!(%task_id, %run_id, "Agent execution completed successfully"); - (logs, answer, "completed".to_string()) + let db_bg = db.clone(); + let config_bg = config.clone(); + let scheduler_bg = _scheduler.clone(); + let task_id_bg = task_id; + + tokio::spawn(async move { + let (logs, answer, status) = match agent.run(&config_bg).await { + Ok((logs, answer)) => { + tracing::info!(task_id = %task_id_bg, run_id = %run_id, "Agent execution completed successfully"); + (logs, answer, "completed".to_string()) + } + Err(e) => { + tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Agent execution failed"); + ( + format!("Execution failed: {}", e), + None, + "failed".to_string(), + ) + } + }; + + let run_update = task_run::ActiveModel { + id: Set(run_id), + logs: Set(logs), + answer: Set(answer), + status: Set(status.clone()), + ..Default::default() + }; + + if let Err(e) = run_update.update(&db_bg).await { + tracing::error!(task_id = %task_id_bg, run_id = %run_id, error = %e, "Failed to update run record"); } - Err(e) => { - tracing::error!(%task_id, %run_id, error = %e, "Agent execution failed"); - ( - format!("Execution failed: {}", e), - None, - "failed".to_string(), - ) - } - }; - let run: task_run::ActiveModel = TaskRun::find_by_id(run_id) - .one(db) - .await - .map_err(crate::error::AppError::Database)? - .ok_or_else(|| crate::error::AppError::NotFound("Run not found after insert".into()))? - .into(); + if let Ok(task_response) = get_task_inner(task_id_bg, &db_bg).await { + let _ = scheduler_bg + .tx + .send(crate::server::notifications::WsEvent::RunFinished( + task_response.clone(), + )); - let mut run = run; - run.logs = Set(logs.clone()); - run.answer = Set(answer.clone()); - run.status = Set(status.clone()); + // Send Push Notifications to subscribers + if let Ok(subscriptions) = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id_bg)) + .all(&db_bg) + .await + { + for sub in subscriptions { + if let Ok(push_subs) = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone())) + .all(&db_bg) + .await + { + for push_sub in push_subs { + let sender = scheduler_bg.push_sender.clone(); + let goal = task_response.goal.clone(); + let status_bg = status.clone(); + let sub_data = crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; - run.update(db) - .await - .map_err(crate::error::AppError::Database)?; - - let task_response = get_task_inner(task_id, db).await?; - let _ = _scheduler - .tx - .send(crate::server::notifications::WsEvent::RunFinished( - task_response.clone(), - )); - - // Send Push Notifications to subscribers - let subscriptions = task_subscription::Entity::find() - .filter(task_subscription::Column::TaskId.eq(task_id)) - .all(db) - .await - .map_err(crate::error::AppError::Database)?; - - tracing::info!( - "Found {} task subscriptions for task {}", - subscriptions.len(), - task_id - ); - - for sub in subscriptions { - let push_subs = push_subscription::Entity::find() - .filter(push_subscription::Column::UserSub.eq(sub.user_sub.clone())) - .all(db) - .await - .map_err(crate::error::AppError::Database)?; - - tracing::info!( - "Found {} push subscriptions for user {}", - push_subs.len(), - sub.user_sub - ); - - for push_sub in push_subs { - let sender = _scheduler.push_sender.clone(); - let goal = task_response.goal.clone(); - let status = status.clone(); - let sub_data = crate::domain::notifications::push::PushSubscription { - endpoint: push_sub.endpoint, - p256dh: push_sub.p256dh, - auth: push_sub.auth, - }; - - tokio::spawn(async move { - if let Err(e) = sender - .send_notification( - &sub_data, - &format!("Task Completed: {}", status), - &goal, - Some(task_id), - Some(run_id), - ) - .await - { - tracing::error!("Failed to send notification in background task: {}", e); + tokio::spawn(async move { + let _ = sender + .send_notification( + &sub_data, + &format!("Task Completed: {}", status_bg), + &goal, + Some(task_id_bg), + Some(run_id), + ) + .await; + }); + } + } } - }); + } } - } + }); - Ok(task_response) + get_task_inner(task_id, db).await } pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult { diff --git a/src/server/chat.rs b/src/server/chat.rs index d6b6bcd..6e24d4b 100644 --- a/src/server/chat.rs +++ b/src/server/chat.rs @@ -52,7 +52,15 @@ pub async fn chat_handler( )?; tracing::info!("Starting interactive agent turn"); - let assistant_message = agent.execute_turn().await?; + let assistant_message = + match tokio::time::timeout(std::time::Duration::from_secs(180), agent.execute_turn()).await + { + Ok(res) => res?, + Err(_) => { + tracing::error!("Interactive chat agent turn timed out after 180s"); + return Err(AppError::Internal("Agent turn timed out".into())); + } + }; Ok(Json(ChatResult { message: assistant_message,