Compare commits

...
3 Commits
Author SHA1 Message Date
dguiducci 587958ffe0 llm request tracking, user context cleanup, minor fixes 2026-07-11 00:23:14 +01:00
dguiducci 5936cf0b2e multiuser part 2 2026-07-10 22:15:35 +01:00
dguiducci c72f18b0d6 Multuser part 1 2026-07-10 22:05:25 +01:00
40 changed files with 939 additions and 969 deletions
Generated
+14 -487
View File
@@ -88,29 +88,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "aquamarine"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
dependencies = [
"include_dir",
"itertools 0.10.5",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -432,12 +409,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -668,15 +639,6 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1147,34 +1109,13 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl 1.0.0",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [ dependencies = [
"derive_more-impl 2.1.1", "derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"unicode-xid",
] ]
[[package]] [[package]]
@@ -1335,16 +1276,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "dptree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db96968fcf52fe063a98c75df1d1f2b1fba304e7ae29b72fdc81c1165b7e2fd0"
dependencies = [
"colored",
"futures",
]
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "1.0.11" version = "1.0.11"
@@ -1475,16 +1406,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erasable"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]] [[package]]
name = "erased-serde" name = "erased-serde"
version = "0.4.10" version = "0.4.10"
@@ -2267,7 +2188,7 @@ checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
name = "honcho-client" name = "honcho-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -2390,7 +2311,6 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.7",
] ]
[[package]] [[package]]
@@ -2567,38 +2487,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -3031,7 +2919,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -3052,12 +2940,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "managed" name = "managed"
version = "0.8.0" version = "0.8.0"
@@ -3097,7 +2979,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -3179,16 +3061,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.19.3" version = "0.19.3"
@@ -3606,15 +3478,6 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -3930,26 +3793,6 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -3992,7 +3835,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -4007,48 +3850,12 @@ dependencies = [
"async-trait", "async-trait",
"core-api", "core-api",
"parking_lot", "parking_lot",
"reqwest 0.13.4", "reqwest",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
] ]
[[package]]
name = "plugin-honcho"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"core-api",
"honcho-client",
"serde_json",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "plugin-mobile-connector"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"chrono",
"core-api",
"hex",
"image",
"qrcode",
"rand 0.9.4",
"serde",
"serde_json",
"skald-relay-client",
"skald-relay-common",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "plugin-tailscale-remote" name = "plugin-tailscale-remote"
version = "0.1.0" version = "0.1.0"
@@ -4064,24 +3871,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "plugin-telegram-bot"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"core-api",
"rand 0.10.1",
"regex",
"serde",
"serde_json",
"teloxide",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "plugin-transcribe-whisper-local" name = "plugin-transcribe-whisper-local"
version = "0.1.0" version = "0.1.0"
@@ -4103,7 +3892,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest 0.13.4", "reqwest",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -4116,7 +3905,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"core-api", "core-api",
"reqwest 0.13.4", "reqwest",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -4445,31 +4234,6 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "psm"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -4479,61 +4243,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -4647,15 +4356,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rc-box"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0"
dependencies = [
"erasable",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -4742,48 +4442,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -4823,19 +4481,10 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.5.0", "wasm-streams",
"web-sys", "web-sys",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -4952,7 +4601,6 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -5122,7 +4770,7 @@ checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cssparser", "cssparser",
"derive_more 2.1.1", "derive_more",
"log", "log",
"new_debug_unreachable", "new_debug_unreachable",
"phf 0.13.1", "phf 0.13.1",
@@ -5475,14 +5123,11 @@ dependencies = [
"notify", "notify",
"plugin-comfyui", "plugin-comfyui",
"plugin-elevenlabs", "plugin-elevenlabs",
"plugin-honcho",
"plugin-mobile-connector",
"plugin-tailscale-remote", "plugin-tailscale-remote",
"plugin-telegram-bot",
"plugin-transcribe-whisper-local", "plugin-transcribe-whisper-local",
"plugin-tts-kokoro", "plugin-tts-kokoro",
"plugin-tts-orpheus-3b", "plugin-tts-orpheus-3b",
"reqwest 0.13.4", "reqwest",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -5529,7 +5174,7 @@ dependencies = [
"quote", "quote",
"rand 0.10.1", "rand 0.10.1",
"regex", "regex",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@@ -5623,7 +5268,7 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"prost", "prost",
"rand 0.8.6", "rand 0.8.6",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@@ -5931,19 +5576,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -6139,18 +5771,6 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "take_mut"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
[[package]]
name = "takecell"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e"
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.35.3" version = "0.35.3"
@@ -6238,7 +5858,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -6421,76 +6041,6 @@ dependencies = [
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]]
name = "teloxide"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84992abeed3ae42e8401b25d266d12bcba1def0abe59d22f6b9781167545f71e"
dependencies = [
"aquamarine",
"bytes",
"derive_more 1.0.0",
"dptree",
"either",
"futures",
"log",
"mime",
"pin-project",
"serde",
"serde_json",
"teloxide-core",
"teloxide-macros",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"url",
]
[[package]]
name = "teloxide-core"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7a34ca8e971fa892e633858c07547fe138ef4a02e4a4eaa1d35e517d6e0bc4"
dependencies = [
"bitflags 2.11.1",
"bytes",
"chrono",
"derive_more 1.0.0",
"either",
"futures",
"log",
"mime",
"once_cell",
"pin-project",
"rc-box",
"reqwest 0.12.28",
"rgb",
"serde",
"serde_json",
"serde_with",
"stacker",
"take_mut",
"takecell",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"url",
"uuid",
]
[[package]]
name = "teloxide-macros"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300fadcaf0c182f19b5ca10bf23a45dc9a48925f00c704405fd90ee2c03942f9"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -8004,19 +7554,6 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@@ -8052,16 +7589,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.5" version = "0.2.5"
-6
View File
@@ -8,9 +8,7 @@ members = [
"crates/core-api", "crates/core-api",
"crates/mcp-client", "crates/mcp-client",
"crates/plugin-tailscale-remote", "crates/plugin-tailscale-remote",
"crates/plugin-honcho",
"crates/plugin-transcribe-whisper-local", "crates/plugin-transcribe-whisper-local",
"crates/plugin-telegram-bot",
"crates/plugin-comfyui", "crates/plugin-comfyui",
"crates/plugin-tts-orpheus-3b", "crates/plugin-tts-orpheus-3b",
"crates/plugin-tts-kokoro", "crates/plugin-tts-kokoro",
@@ -18,7 +16,6 @@ members = [
"crates/skald-relay-common", "crates/skald-relay-common",
"crates/skald-relay-server", "crates/skald-relay-server",
"crates/skald-relay-client", "crates/skald-relay-client",
"crates/plugin-mobile-connector",
] ]
resolver = "2" resolver = "2"
@@ -84,14 +81,11 @@ llm-client = { path = "crates/llm-client" }
core-api = { path = "crates/core-api" } core-api = { path = "crates/core-api" }
mcp-client = { path = "crates/mcp-client" } mcp-client = { path = "crates/mcp-client" }
plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" } plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" }
plugin-honcho = { path = "crates/plugin-honcho" }
plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true } plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true }
plugin-telegram-bot = { path = "crates/plugin-telegram-bot" }
plugin-comfyui = { path = "crates/plugin-comfyui" } plugin-comfyui = { path = "crates/plugin-comfyui" }
plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" } plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" }
plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" } plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" }
plugin-elevenlabs = { path = "crates/plugin-elevenlabs" } plugin-elevenlabs = { path = "crates/plugin-elevenlabs" }
plugin-mobile-connector = { path = "crates/plugin-mobile-connector" }
# ── Desktop bundle (Tauri) ─────────────────────────────────────────────────── # ── Desktop bundle (Tauri) ───────────────────────────────────────────────────
# Optional, activated by the `desktop` feature. Wraps the headless server in a # Optional, activated by the `desktop` feature. Wraps the headless server in a
+7
View File
@@ -39,6 +39,13 @@ pub struct ChatOptions {
/// providers — only the logging wrapper reads them. /// providers — only the logging wrapper reads them.
pub session_id: Option<i64>, pub session_id: Option<i64>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
/// The authenticated user driving this request. Correlates the metadata row
/// in `system.db` with the payload in `{userid}.db`. Logging-only.
pub user_id: Option<String>,
/// UUID correlating the metadata row (`llm_requests`) with the payload row
/// (`llm_request_payloads`). Generated by the LLM loop before the call.
/// Logging-only.
pub request_id: Option<String>,
} }
/// Raw HTTP metadata captured during a provider call. /// Raw HTTP metadata captured during a provider call.
-6
View File
@@ -6,7 +6,6 @@ use serde_json::Value;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::approval::ApprovalApi; use crate::approval::ApprovalApi;
use crate::bus::ChatEventBus;
use crate::command::CommandApi; use crate::command::CommandApi;
use crate::system_bus::SystemEventBus; use crate::system_bus::SystemEventBus;
use crate::chat_hub::ChatHubApi; use crate::chat_hub::ChatHubApi;
@@ -31,13 +30,9 @@ pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
/// `RemotePlugin`. /// `RemotePlugin`.
#[derive(Clone)] #[derive(Clone)]
pub struct PluginContext { pub struct PluginContext {
pub chat_hub: Arc<dyn ChatHubApi>,
/// Custom file-based slash commands (`commands/<name>/`). Read-only from the /// Custom file-based slash commands (`commands/<name>/`). Read-only from the
/// plugin side — lets the Telegram bot resolve `/command` expansions. /// plugin side — lets the Telegram bot resolve `/command` expansions.
pub command: Arc<dyn CommandApi>, pub command: Arc<dyn CommandApi>,
pub approval: Arc<dyn ApprovalApi>,
/// Unified Inbox façade (approvals + clarifications). See plugin.md §12.2.
pub inbox: Arc<dyn InboxApi>,
/// Skald's shared SQLite pool — lets plugins create/use their own tables /// Skald's shared SQLite pool — lets plugins create/use their own tables
/// (e.g. `relay_*`) in the main DB. See plugin.md §12.1. /// (e.g. `relay_*`) in the main DB. See plugin.md §12.1.
pub db: Arc<sqlx::SqlitePool>, pub db: Arc<sqlx::SqlitePool>,
@@ -49,7 +44,6 @@ pub struct PluginContext {
pub tts_provider: Arc<dyn TtsProvider>, pub tts_provider: Arc<dyn TtsProvider>,
pub api_provider_registry: Arc<dyn ApiProviderRegistry>, pub api_provider_registry: Arc<dyn ApiProviderRegistry>,
pub location: Arc<dyn LocationUpdater>, pub location: Arc<dyn LocationUpdater>,
pub event_bus: Arc<ChatEventBus>,
pub system_bus: Arc<SystemEventBus>, pub system_bus: Arc<SystemEventBus>,
pub web_port: u16, pub web_port: u16,
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>, pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
+28 -1
View File
@@ -1,6 +1,6 @@
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::Value;
@@ -34,6 +34,24 @@ pub enum ToolCategory {
Config, Config,
} }
// ── ToolContext ───────────────────────────────────────────────────────────────
/// Per-invocation execution context threaded into a tool's [`Tool::run_with`].
///
/// Carries the identity of the session driving the call and the owner's database
/// pool. Owner-bound tools (e.g. cron management) read `pool` to act on the
/// caller's own `{userid}.db` rather than a manager captured globally at
/// registration time. Context-free tools ignore it — the default `run_with`
/// delegates to `run`, so most tools need no change.
#[derive(Clone)]
pub struct ToolContext {
/// The session that issued this tool call. Ids are local to `pool`.
pub session_id: i64,
/// The owner's unlocked database pool (per-user in multi-user mode; the shared
/// `system.db` in the transitional single-pool state).
pub pool: Arc<sqlx::SqlitePool>,
}
// ── Tool trait ──────────────────────────────────────────────────────────────── // ── Tool trait ────────────────────────────────────────────────────────────────
/// A single LLM-callable tool. /// A single LLM-callable tool.
@@ -91,6 +109,15 @@ pub trait Tool: Send + Sync {
Box::new(SimpleExecution::new(self.execute_typed(args))) Box::new(SimpleExecution::new(self.execute_typed(args)))
} }
/// Context-aware variant of [`run`](Self::run): the session driver threads a
/// [`ToolContext`] (session id + owner pool) so owner-bound tools can act on
/// the caller's own database instead of a globally-captured manager. The
/// default ignores the context and delegates to `run`, so context-free tools
/// need no change.
fn run_with<'a>(&'a self, _ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
self.run(args)
}
/// Logical category of this tool. /// Logical category of this tool.
fn category(&self) -> ToolCategory; fn category(&self) -> ToolCategory;
+29 -53
View File
@@ -1,11 +1,12 @@
//! Transparent logging wrapper for any [`ChatbotClient`]. //! Transparent logging wrapper for any [`ChatbotClient`].
//! //!
//! [`LoggingChatbotClient`] intercepts every `chat_with_tools` call, captures //! [`LoggingChatbotClient`] intercepts every `chat_with_tools_raw` call, captures
//! the raw HTTP request/response from the inner provider via `chat_with_tools_raw`, //! the raw HTTP request/response from the inner provider, persists a **metadata-only**
//! then persists a row to `llm_requests` asynchronously (fire-and-forget). //! row to `llm_requests` in `system.db` (fire-and-forget), then returns the raw data
//! to the caller so it can write the **payload** to the user's own database.
//! //!
//! The LLM loop is completely unaware of this: it only holds an //! The split keeps conversation content (payloads) behind the user key while
//! `Arc<dyn ChatbotClient>` and calls `chat_with_tools` as usual. //! metadata (cost, tokens, timing) stays in the admin-readable registry.
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -21,26 +22,10 @@ use super::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Messa
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Controls which parts of the HTTP exchange are persisted per row.
#[derive(Debug, Clone, Copy)]
pub struct LogSaveFlags {
pub request_payload: bool,
pub response_payload: bool,
pub request_headers: bool,
pub response_headers: bool,
}
impl Default for LogSaveFlags {
fn default() -> Self {
Self { request_payload: true, response_payload: true, request_headers: true, response_headers: true }
}
}
pub struct LoggingChatbotClient { pub struct LoggingChatbotClient {
inner: Arc<dyn ChatbotClient>, inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
model_name: String, model_name: String,
flags: LogSaveFlags,
} }
impl LoggingChatbotClient { impl LoggingChatbotClient {
@@ -48,9 +33,8 @@ impl LoggingChatbotClient {
inner: Arc<dyn ChatbotClient>, inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
model_name: impl Into<String>, model_name: impl Into<String>,
flags: LogSaveFlags,
) -> Self { ) -> Self {
Self { inner, pool, model_name: model_name.into(), flags } Self { inner, pool, model_name: model_name.into() }
} }
} }
@@ -65,20 +49,35 @@ impl ChatbotClient for LoggingChatbotClient {
self.inner.chat(messages, options).await self.inner.chat(messages, options).await
} }
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture /// Passthrough that drops the raw meta. Used by callers that do not need
/// HTTP wire data, then spawns a fire-and-forget DB write before returning. /// payload capture (e.g. the compactor).
async fn chat_with_tools( async fn chat_with_tools(
&self, &self,
messages: &[Value], messages: &[Value],
tools: &[Value], tools: &[Value],
options: &ChatOptions, options: &ChatOptions,
) -> anyhow::Result<LlmTurn> { ) -> anyhow::Result<LlmTurn> {
let (turn, _) = self.chat_with_tools_raw(messages, tools, options).await?;
Ok(turn)
}
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture
/// HTTP wire data, writes a **metadata-only** row to `system.db`, then returns
/// the raw data so the caller can persist payloads to the user's own database.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let start = Instant::now(); let start = Instant::now();
let result = self.inner.chat_with_tools_raw(messages, tools, options).await; let result = self.inner.chat_with_tools_raw(messages, tools, options).await;
let duration_ms = start.elapsed().as_millis() as i64; let duration_ms = start.elapsed().as_millis() as i64;
let session_id = options.session_id; let session_id = options.session_id;
let stack_id = options.stack_id; let stack_id = options.stack_id;
let user_id = options.user_id.clone();
let request_id = options.request_id.clone();
let model_name = self.model_name.clone(); let model_name = self.model_name.clone();
let pool = Arc::clone(&self.pool); let pool = Arc::clone(&self.pool);
@@ -90,24 +89,13 @@ impl ChatbotClient for LoggingChatbotClient {
(*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens), (*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens),
}; };
let meta = meta.unwrap_or_default();
let flags = self.flags;
let request_json = if flags.request_payload {
meta.request_body.map(|v| v.to_string()).unwrap_or_default()
} else { String::new() };
let request_headers = if flags.request_headers { meta.request_headers.map(|v| v.to_string()) } else { None };
let response_json = if flags.response_payload { meta.response_body.map(|v| v.to_string()) } else { None };
let response_headers = if flags.response_headers { meta.response_headers.map(|v| v.to_string()) } else { None };
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id, session_id,
stack_id, stack_id,
model_name, model_name,
request_json,
request_headers,
response_json,
response_headers,
error_text: None, error_text: None,
input_tokens: input_tokens.map(|n| n as i64), input_tokens: input_tokens.map(|n| n as i64),
output_tokens: output_tokens.map(|n| n as i64), output_tokens: output_tokens.map(|n| n as i64),
@@ -119,7 +107,7 @@ impl ChatbotClient for LoggingChatbotClient {
} }
}); });
Ok(turn) Ok((turn, meta))
} }
Err(e) => { Err(e) => {
@@ -127,13 +115,11 @@ impl ChatbotClient for LoggingChatbotClient {
tokio::spawn(async move { tokio::spawn(async move {
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id, session_id,
stack_id, stack_id,
model_name, model_name,
request_json: String::new(),
request_headers: None,
response_json: None,
response_headers: None,
error_text: Some(error_text), error_text: Some(error_text),
input_tokens: None, input_tokens: None,
output_tokens: None, output_tokens: None,
@@ -149,14 +135,4 @@ impl ChatbotClient for LoggingChatbotClient {
} }
} }
} }
/// Expose raw metadata so this wrapper can itself be wrapped if needed.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.inner.chat_with_tools_raw(messages, tools, options).await
}
} }
+2
View File
@@ -313,6 +313,8 @@ impl ContextCompactor {
temperature: Some(0.3), temperature: Some(0.3),
session_id: Some(session_id), session_id: Some(session_id),
stack_id: Some(stack_id), stack_id: Some(stack_id),
user_id: None,
request_id: None,
}; };
let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await
@@ -0,0 +1,32 @@
//! DB operations for the `llm_request_payloads` table (owner bucket).
//!
//! Full request/response payloads + headers for each LLM call. Lives in
//! `{userid}.db` (encrypted), correlated with the metadata row in `system.db`
//! via `request_id`.
use anyhow::Result;
use sqlx::SqlitePool;
pub struct PayloadRow {
pub request_json: String,
pub request_headers: Option<String>,
pub response_json: Option<String>,
pub response_headers: Option<String>,
pub request_id: String,
}
pub async fn insert(pool: &SqlitePool, row: PayloadRow) -> Result<()> {
sqlx::query(
"INSERT INTO llm_request_payloads
(request_id, request_json, request_headers, response_json, response_headers)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&row.request_id)
.bind(&row.request_json)
.bind(&row.request_headers)
.bind(&row.response_json)
.bind(&row.response_headers)
.execute(pool)
.await?;
Ok(())
}
@@ -1,9 +1,9 @@
//! Background maintenance task for the `llm_requests` table. //! Background maintenance task for the `llm_requests` table.
//! //!
//! Periodically nulls out old payloads/headers and deletes expired rows according //! Deletes expired metadata rows according to the retention settings in
//! to the retention settings in [`LlmRequestsLogConfig`], then `VACUUM`s to reclaim //! [`LlmRequestsLogConfig`], then `VACUUM`s to reclaim freed pages.
//! freed pages. Extracted from `Skald::new` so the loop lives next to the queries it //! Payload/header nulling is gone — those columns moved to `llm_request_payloads`
//! calls; the returned handle is registered with the `TaskSupervisor` for shutdown. //! in the owner bucket.
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -31,27 +31,6 @@ pub fn spawn(
_ = tokio::time::sleep(Duration::from_secs(60)) => {} _ = tokio::time::sleep(Duration::from_secs(60)) => {}
} }
loop { loop {
if let Some(days) = cfg.cleanup_request_payload_after {
match super::null_request_payload(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled request payload"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null request payload failed"),
}
}
if let Some(days) = cfg.cleanup_response_payload_after {
match super::null_response_payload(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled response payload"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null response payload failed"),
}
}
if let Some(days) = cfg.cleanup_headers_after {
match super::null_headers(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled headers"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null headers failed"),
}
}
if let Some(days) = cfg.cleanup_rows_after { if let Some(days) = cfg.cleanup_rows_after {
match super::delete_old_rows(&pool, days).await { match super::delete_old_rows(&pool, days).await {
Ok(n) if n > 0 => info!(deleted = n, days, "llm_requests: deleted old rows"), Ok(n) if n > 0 => info!(deleted = n, days, "llm_requests: deleted old rows"),
@@ -59,7 +38,7 @@ pub fn spawn(
Err(e) => warn!(error = %e, "llm_requests: delete old rows failed"), Err(e) => warn!(error = %e, "llm_requests: delete old rows failed"),
} }
} }
// VACUUM reclaims pages freed by DELETE/UPDATE NULL. // VACUUM reclaims pages freed by DELETE.
match sqlx::query("VACUUM").execute(&*pool).await { match sqlx::query("VACUUM").execute(&*pool).await {
Ok(_) => info!("llm_requests: VACUUM complete"), Ok(_) => info!("llm_requests: VACUUM complete"),
Err(e) => warn!(error = %e, "llm_requests: VACUUM failed"), Err(e) => warn!(error = %e, "llm_requests: VACUUM failed"),
+9 -61
View File
@@ -1,7 +1,9 @@
//! DB operations for the `llm_requests` table. //! DB operations for the `llm_requests` table (metadata only).
//! //!
//! Every `chat_with_tools` call is logged here by the //! Every `chat_with_tools` call is logged here by the
//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper. //! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper.
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
//! Rows are retained for `llm.request_log.retention_days` days (default 14). //! Rows are retained for `llm.request_log.retention_days` days (default 14).
use anyhow::Result; use anyhow::Result;
@@ -12,17 +14,11 @@ pub mod cleanup;
// ── Row struct ──────────────────────────────────────────────────────────────── // ── Row struct ────────────────────────────────────────────────────────────────
pub struct LlmRequestRow { pub struct LlmRequestRow {
pub request_id: Option<String>,
pub user_id: Option<String>,
pub session_id: Option<i64>, pub session_id: Option<i64>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
pub model_name: String, pub model_name: String,
/// Full HTTP request body sent to the provider (compact JSON, no pretty-print).
pub request_json: String,
/// HTTP request headers as a compact JSON object (api-key redacted).
pub request_headers: Option<String>,
/// Full HTTP response body from the provider (compact JSON).
pub response_json: Option<String>,
/// HTTP response headers as a compact JSON object.
pub response_headers: Option<String>,
/// Error message when the HTTP call itself failed (no response available). /// Error message when the HTTP call itself failed (no response available).
pub error_text: Option<String>, pub error_text: Option<String>,
pub input_tokens: Option<i64>, pub input_tokens: Option<i64>,
@@ -40,21 +36,17 @@ pub struct LlmRequestRow {
pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> { pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
let id = sqlx::query_scalar::<_, i64>( let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO llm_requests ( "INSERT INTO llm_requests (
session_id, stack_id, model_name, request_id, user_id, session_id, stack_id, model_name,
request_json, request_headers,
response_json, response_headers,
error_text, input_tokens, output_tokens, duration_ms, error_text, input_tokens, output_tokens, duration_ms,
cache_read_tokens, cache_creation_tokens cache_read_tokens, cache_creation_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id", RETURNING id",
) )
.bind(&row.request_id)
.bind(&row.user_id)
.bind(row.session_id) .bind(row.session_id)
.bind(row.stack_id) .bind(row.stack_id)
.bind(&row.model_name) .bind(&row.model_name)
.bind(&row.request_json)
.bind(&row.request_headers)
.bind(&row.response_json)
.bind(&row.response_headers)
.bind(&row.error_text) .bind(&row.error_text)
.bind(row.input_tokens) .bind(row.input_tokens)
.bind(row.output_tokens) .bind(row.output_tokens)
@@ -79,47 +71,3 @@ pub async fn delete_old_rows(pool: &SqlitePool, days: u32) -> Result<u64> {
.rows_affected(); .rows_affected();
Ok(n) Ok(n)
} }
/// Nulls out `request_json` for rows older than `days` days. Returns rows affected.
pub async fn null_request_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests SET request_json = '' \
WHERE request_json != '' AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
/// Nulls out `response_json` for rows older than `days` days. Returns rows affected.
pub async fn null_response_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests SET response_json = NULL \
WHERE response_json IS NOT NULL AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
/// Nulls out both header columns for rows older than `days` days. Returns rows affected.
pub async fn null_headers(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests \
SET request_headers = NULL, response_headers = NULL \
WHERE (request_headers IS NOT NULL OR response_headers IS NOT NULL) \
AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
+24 -4
View File
@@ -10,6 +10,7 @@ pub mod config;
pub mod job_runs; pub mod job_runs;
pub mod known_tools; pub mod known_tools;
pub mod llm_requests; pub mod llm_requests;
pub mod llm_request_payloads;
pub mod mcp_events; pub mod mcp_events;
pub mod mcp_servers; pub mod mcp_servers;
pub mod plugins; pub mod plugins;
@@ -317,16 +318,17 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
// decrypting anything: the admin sees how much, when and which model — never // decrypting anything: the admin sees how much, when and which model — never
// what was said. `session_id` / `stack_id` are bare integers, not foreign // what was said. `session_id` / `stack_id` are bare integers, not foreign
// keys, precisely because the rows they point at live in another file. // keys, precisely because the rows they point at live in another file.
// `user_id` correlates the row with the payload in `{userid}.db`.
// Payloads (request/response bodies, headers) live in `llm_request_payloads`
// in the owner bucket — they are conversation content, behind the user key.
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS llm_requests ( "CREATE TABLE IF NOT EXISTS llm_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT,
user_id TEXT,
session_id INTEGER, session_id INTEGER,
stack_id INTEGER, stack_id INTEGER,
model_name TEXT NOT NULL, model_name TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
request_headers TEXT,
response_json TEXT,
response_headers TEXT,
error_text TEXT, error_text TEXT,
input_tokens INTEGER, input_tokens INTEGER,
output_tokens INTEGER, output_tokens INTEGER,
@@ -691,6 +693,23 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// Full request/response payloads for telemetry. Lives in the owner bucket
// (per-user, encrypted) because it is conversation content. Correlated with
// the metadata row in `system.db` via `request_id` (uuid).
sqlx::query(
"CREATE TABLE IF NOT EXISTS llm_request_payloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
request_headers TEXT,
response_json TEXT,
response_headers TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
Ok(()) Ok(())
} }
@@ -744,6 +763,7 @@ mod tests {
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap(); one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap(); one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap();
one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap(); one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
pool.close().await; pool.close().await;
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
+2
View File
@@ -456,6 +456,8 @@ mod tests {
async fn cleartext_user_round_trips_with_and_without_a_verifier() { async fn cleartext_user_round_trips_with_and_without_a_verifier() {
let path = temp_db_path("users-clear"); let path = temp_db_path("users-clear");
let pool = crate::db::init_system_pool(&path).await.unwrap(); let pool = crate::db::init_system_pool(&path).await.unwrap();
crate::db::roles::insert(&pool, "children", "Children", "default", None)
.await.unwrap();
insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap(); insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap();
insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap(); insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap();
+10 -10
View File
@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::chatbot::ChatbotClient; use crate::chatbot::ChatbotClient;
use crate::chatbot::logging::{LoggingChatbotClient, LogSaveFlags}; use crate::chatbot::logging::LoggingChatbotClient;
use core_api::provider::LlmStrength; use core_api::provider::LlmStrength;
use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode}; use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode};
@@ -69,15 +69,15 @@ pub struct LlmManager {
catalog: RwLock<HashMap<i64, CachedCatalog>>, catalog: RwLock<HashMap<i64, CachedCatalog>>,
/// Per-model metadata cache, keyed by model display name. TTL = 1h. /// Per-model metadata cache, keyed by model display name. TTL = 1h.
model_meta_cache: RwLock<HashMap<String, CachedModelMeta>>, model_meta_cache: RwLock<HashMap<String, CachedModelMeta>>,
/// When `Some`, every LLM entry is wrapped with [`LoggingChatbotClient`]. /// When `true`, every LLM entry is wrapped with [`LoggingChatbotClient`].
log_flags: Option<LogSaveFlags>, log_enabled: bool,
} }
impl LlmManager { impl LlmManager {
pub async fn new( pub async fn new(
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
registry: Arc<ProviderRegistry>, registry: Arc<ProviderRegistry>,
log_flags: Option<LogSaveFlags>, log_enabled: bool,
) -> Result<Arc<Self>> { ) -> Result<Arc<Self>> {
let mgr = Arc::new(Self { let mgr = Arc::new(Self {
pool, pool,
@@ -89,7 +89,7 @@ impl LlmManager {
}), }),
catalog: RwLock::new(HashMap::new()), catalog: RwLock::new(HashMap::new()),
model_meta_cache: RwLock::new(HashMap::new()), model_meta_cache: RwLock::new(HashMap::new()),
log_flags, log_enabled,
}); });
mgr.reload().await?; mgr.reload().await?;
Ok(mgr) Ok(mgr)
@@ -461,9 +461,9 @@ impl LlmManager {
} }
}; };
let log_config = self.log_flags.map(|f| (Arc::clone(&self.pool), f)); let log_pool = self.log_enabled.then(|| Arc::clone(&self.pool));
let entry = match build_entry(&self.registry, &provider, &model, model.id, log_config) { let entry = match build_entry(&self.registry, &provider, &model, model.id, log_pool) {
Ok(e) => Arc::new(e), Ok(e) => Arc::new(e),
Err(e) => { Err(e) => {
warn!(model = %model.name, error = %e, "failed to build LLM entry, skipping"); warn!(model = %model.name, error = %e, "failed to build LLM entry, skipping");
@@ -505,7 +505,7 @@ fn build_entry(
provider: &LlmProviderRecord, provider: &LlmProviderRecord,
model: &LlmModelRecord, model: &LlmModelRecord,
model_db_id: i64, model_db_id: i64,
log_config: Option<(Arc<SqlitePool>, LogSaveFlags)>, log_pool: Option<Arc<SqlitePool>>,
) -> Result<LlmEntry> { ) -> Result<LlmEntry> {
let built = registry.get(&provider.provider) let built = registry.get(&provider.provider)
.ok_or_else(|| anyhow::anyhow!("unknown provider type '{}'", provider.provider))? .ok_or_else(|| anyhow::anyhow!("unknown provider type '{}'", provider.provider))?
@@ -516,8 +516,8 @@ fn build_entry(
let prompt_cache = built.prompt_cache; let prompt_cache = built.prompt_cache;
let extra = model.extra_params.clone(); let extra = model.extra_params.clone();
let client: Arc<dyn ChatbotClient> = match log_config { let client: Arc<dyn ChatbotClient> = match log_pool {
Some((pool, flags)) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name, flags)), Some(pool) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name)),
None => inner, None => inner,
}; };
-4
View File
@@ -96,10 +96,7 @@ impl PluginManager {
.ok_or_else(|| anyhow::anyhow!("PluginManager: web_port not set"))?; .ok_or_else(|| anyhow::anyhow!("PluginManager: web_port not set"))?;
Ok(PluginContext { Ok(PluginContext {
chat_hub: Arc::clone(skald.chat_hub()) as _,
command: Arc::clone(skald.command_manager()) as _, command: Arc::clone(skald.command_manager()) as _,
approval: Arc::clone(skald.approval()) as _,
inbox: Arc::new(skald.inbox().clone()) as _,
db: Arc::clone(skald.db()), db: Arc::clone(skald.db()),
secrets: Arc::clone(skald.secrets()) as _, secrets: Arc::clone(skald.secrets()) as _,
transcribe: Arc::clone(skald.transcribe_manager()) as _, transcribe: Arc::clone(skald.transcribe_manager()) as _,
@@ -109,7 +106,6 @@ impl PluginManager {
tts_provider: Arc::clone(skald.tts_manager()) as _, tts_provider: Arc::clone(skald.tts_manager()) as _,
api_provider_registry: Arc::clone(skald.provider_registry()) as _, api_provider_registry: Arc::clone(skald.provider_registry()) as _,
location: Arc::clone(skald.location_manager()) as _, location: Arc::clone(skald.location_manager()) as _,
event_bus: Arc::clone(skald.event_bus()),
system_bus: Arc::clone(skald.system_bus()), system_bus: Arc::clone(skald.system_bus()),
web_port, web_port,
remote_slot: Arc::clone(skald.remote()), remote_slot: Arc::clone(skald.remote()),
@@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, warn}; use tracing::{error, warn};
use crate::chatbot::{ChatOptions, LlmTurn}; use crate::chatbot::{ChatOptions, LlmTurn};
use crate::db::llm_request_payloads;
use crate::llm::{LlmEntry, LlmStrength}; use crate::llm::{LlmEntry, LlmStrength};
use super::ChatSessionHandler; use super::ChatSessionHandler;
@@ -54,12 +55,15 @@ impl ChatSessionHandler {
let mut tried_this_round: Vec<String> = vec![cur_name.clone()]; let mut tried_this_round: Vec<String> = vec![cur_name.clone()];
loop { loop {
let request_id = uuid::Uuid::new_v4().to_string();
let options = ChatOptions { let options = ChatOptions {
model: cur_llm.model.clone(), model: cur_llm.model.clone(),
max_tokens: None, max_tokens: None,
temperature: None, temperature: None,
session_id: Some(self.session_id), session_id: Some(self.session_id),
stack_id: Some(stack_id), stack_id: Some(stack_id),
user_id: Some(self.user_id.clone()),
request_id: Some(request_id.clone()),
}; };
// Clone the Arc so the in-flight future does not borrow `cur_llm` across // Clone the Arc so the in-flight future does not borrow `cur_llm` across
@@ -68,13 +72,33 @@ impl ChatSessionHandler {
let client = cur_llm.client.clone(); let client = cur_llm.client.clone();
let call_result = tokio::select! { let call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled, _ = token.cancelled() => return RoundLlm::Cancelled,
r = client.chat_with_tools(messages.as_slice(), tool_defs, &options) => r, r = client.chat_with_tools_raw(messages.as_slice(), tool_defs, &options) => r,
}; };
let e = match call_result { let e = match call_result {
Ok(t) => { Ok((turn, meta)) => {
self.llm_manager.mark_success(cur_name).await; self.llm_manager.mark_success(cur_name).await;
return RoundLlm::Turn(t); // Persist the payload (request/response bodies + headers) to the
// user's own database. Fire-and-forget — a failed write must not
// break the turn. The metadata row is already written by the
// logging wrapper to system.db with the same request_id.
if let Some(meta) = meta {
let pool = Arc::clone(&self.db);
let rid = request_id.clone();
tokio::spawn(async move {
let row = llm_request_payloads::PayloadRow {
request_id: rid,
request_json: meta.request_body.map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.map(|v| v.to_string()),
response_json: meta.response_body.map(|v| v.to_string()),
response_headers: meta.response_headers.map(|v| v.to_string()),
};
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert");
}
});
}
return RoundLlm::Turn(turn);
} }
Err(e) => e, Err(e) => e,
}; };
@@ -9,7 +9,7 @@ use crate::chatbot::{LlmTurn, ToolCall};
use crate::db::{chat_history, chat_llm_tools}; use crate::db::{chat_history, chat_llm_tools};
use crate::events::ServerEvent; use crate::events::ServerEvent;
use crate::tools::{ use crate::tools::{
ExecutionOutcome, SimpleExecution, ToolDescriptionLength, ToolExecution, ToolResult, ExecutionOutcome, SimpleExecution, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
}; };
use futures::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
@@ -426,6 +426,9 @@ impl ChatSessionHandler {
} }
// Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills // Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills
// the child via kill_on_drop when the work future is dropped on /stop). // the child via kill_on_drop when the work future is dropped on /stop).
self.tools.run(name, args) // The ToolContext carries this session's id and owner pool so owner-bound
// registry tools (e.g. cron management) act on the caller's own database.
let ctx = ToolContext { session_id: self.session_id, pool: Arc::clone(&self.db) };
self.tools.run(name, &ctx, args)
} }
} }
@@ -262,6 +262,9 @@ impl ApprovalDecision {
pub struct ChatSessionHandler { pub struct ChatSessionHandler {
pub session_id: i64, pub session_id: i64,
pub(super) db: Arc<SqlitePool>, pub(super) db: Arc<SqlitePool>,
/// The authenticated user who owns this session. Threaded into `ChatOptions`
/// so the telemetry metadata row in `system.db` carries `user_id`.
pub(super) user_id: String,
pub(super) llm_manager: Arc<LlmManager>, pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize, pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize, pub(super) max_tool_rounds: usize,
@@ -329,6 +332,7 @@ impl ChatSessionHandler {
pub fn new( pub fn new(
session_id: i64, session_id: i64,
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -353,6 +357,7 @@ impl ChatSessionHandler {
Self { Self {
session_id, session_id,
db, db,
user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
max_tool_rounds, max_tool_rounds,
+4
View File
@@ -22,6 +22,7 @@ use super::handler::ChatSessionHandler;
pub struct ChatSessionManager { pub struct ChatSessionManager {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -47,6 +48,7 @@ pub struct ChatSessionManager {
impl ChatSessionManager { impl ChatSessionManager {
pub fn new( pub fn new(
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -66,6 +68,7 @@ impl ChatSessionManager {
) -> Self { ) -> Self {
Self { Self {
db, db,
user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
max_tool_rounds, max_tool_rounds,
@@ -152,6 +155,7 @@ impl ChatSessionManager {
let handler = Arc::new(ChatSessionHandler::new( let handler = Arc::new(ChatSessionHandler::new(
session_id, session_id,
self.db.clone(), self.db.clone(),
self.user_id.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
self.max_tool_rounds, self.max_tool_rounds,
+11
View File
@@ -52,6 +52,17 @@ impl Skald {
// Runtime / cross-cutting // Runtime / cross-cutting
pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db } pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db }
pub fn users(&self) -> &Arc<UserManager> { &self.rt.users } pub fn users(&self) -> &Arc<UserManager> { &self.rt.users }
/// The caller's per-user owner-bound runtime (chat/hub/cron/interaction),
/// built lazily on first use. `None` when the user's database is still locked
/// (not logged in). The pool is the unlock token (§9); a present pool means an
/// unlocked database, so a context can be built for it.
pub async fn user_context(&self, user_id: &str) -> Option<Arc<super::UserContext>> {
let pool = self.rt.users.pool_of(user_id)?;
self.rt_user_contexts().resolve(user_id, pool).await.ok()
}
fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts }
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions } pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config } pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
+4 -11
View File
@@ -72,16 +72,8 @@ impl Models {
let provider_registry = Arc::new(provider_registry); let provider_registry = Arc::new(provider_registry);
info!("provider registry ready ({} built-in providers)", provider_registry.all().len()); info!("provider registry ready ({} built-in providers)", provider_registry.all().len());
let log_flags = config.llm.requests_log.as_ref().filter(|r| r.enabled).map(|r| { let log_enabled = config.llm.requests_log.as_ref().is_some_and(|r| r.enabled);
use crate::chatbot::logging::LogSaveFlags; let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_enabled).await?;
LogSaveFlags {
request_payload: r.request_payload_save,
response_payload: r.response_payload_save,
request_headers: r.request_header_save,
response_headers: r.response_header_save,
}
});
let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_flags).await?;
let client_count = llm_manager.client_names().await.len().saturating_sub(1); let client_count = llm_manager.client_names().await.len().saturating_sub(1);
let default_client = llm_manager.default_name().await; let default_client = llm_manager.default_name().await;
info!(clients = client_count, default = %default_client, "LLM clients loaded"); info!(clients = client_count, default = %default_client, "LLM clients loaded");
@@ -226,7 +218,7 @@ impl Tools {
Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron))); Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp))); tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp)));
tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp))); tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp)));
tool_registry.register(crate::tools::cron_jobs::DeleteCronJob(Arc::clone(&tasks.cron))); tool_registry.register(crate::tools::cron_jobs::DeleteCronJob);
tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets))); tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager))); tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager)));
@@ -346,6 +338,7 @@ impl Conversation {
let manager = Arc::new(ChatSessionManager::new( let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db), Arc::clone(&rt.db),
String::new(),
Arc::clone(&models.llm_manager), Arc::clone(&models.llm_manager),
config.llm.max_history_messages, config.llm.max_history_messages,
config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS), config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
+14
View File
@@ -23,10 +23,13 @@ mod accessors;
mod bundles; mod bundles;
mod runtime; mod runtime;
mod supervisor; mod supervisor;
mod user_context;
mod wiring; mod wiring;
use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tasks, Tools}; use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tasks, Tools};
use runtime::Runtime; use runtime::Runtime;
use user_context::{UserContextFactory, UserContextRegistry};
pub use user_context::UserContext;
use wiring::{spawn_background, wire}; use wiring::{spawn_background, wire};
pub struct Skald { pub struct Skald {
@@ -39,6 +42,10 @@ pub struct Skald {
conversation: Conversation, conversation: Conversation,
interaction: Interaction, interaction: Interaction,
infra: Infra, infra: Infra,
/// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on
/// first use after a user's pool is unlocked. The global bundles above still
/// serve deferred subsystems and the not-yet-migrated call sites.
user_contexts: UserContextRegistry,
} }
impl Skald { impl Skald {
@@ -68,8 +75,15 @@ impl Skald {
wire(&tasks, &conversation, &integrations, &interaction); wire(&tasks, &conversation, &integrations, &interaction);
spawn_background(&rt, &tasks, &conversation, &integrations, config); spawn_background(&rt, &tasks, &conversation, &integrations, config);
// Per-user context factory: captures the global capability managers, so a
// per-user chat/hub/cron/interaction stack can be stamped out on demand.
let user_contexts = UserContextRegistry::new(UserContextFactory::new(
&rt, &models, &media, &tools, &integrations, &conversation, config,
));
let skald = Arc::new(Skald { let skald = Arc::new(Skald {
rt, models, media, tools, integrations, tasks, conversation, interaction, infra, rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
user_contexts,
}); });
// Inject the fully-constructed instance into the plugin manager — the one // Inject the fully-constructed instance into the plugin manager — the one
+259
View File
@@ -0,0 +1,259 @@
//! Per-user runtime context (blueprint §9 / §11 / §5.1).
//!
//! The owner-bound managers — chat sessions, chat hub, cron, and the
//! approval/clarification/elicitation/inbox interaction stack — must operate on
//! *one* user's `{userid}.db` and emit on *one* user's server→client channel, so
//! that no chat content, WS event, job or pending approval crosses between users.
//! They are built **lazily** on first use after the user's pool is unlocked, and
//! live exactly as long as that pool (§9: from first login until restart).
//!
//! `UserManager` stays the pool-lifecycle owner (§11 boundary). This factory sits
//! at the `Skald` layer, where the global *capability* managers (LLM, tools, MCP,
//! memory, providers) are visible, and stamps out the per-user instances against a
//! given pool, wiring their construction cycles and starting the per-user cron
//! loop. Capability managers are shared by reference; only owner-bound state is
//! per-user.
//!
//! Split of pools inside one context: session/history/jobs/hub use the **user
//! pool**; approval *rules* and `known_tools` are instance-wide registry data, so
//! `ApprovalManager` and `ToolDiscovery` read the **registry pool** (`system.db`)
//! while `ApprovalManager` still emits on the user's channel and keeps its own
//! pending map.
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use chrono_tz::Tz;
use sqlx::SqlitePool;
use tokio::sync::{broadcast, Mutex};
use tokio_util::sync::CancellationToken;
use core_api::events::GlobalEvent;
use core_api::system_bus::SystemEventBus;
use crate::approval::ApprovalManager;
use crate::chat_event_bus::ChatEventBus;
use crate::chat_hub::ChatHub;
use crate::clarification::ClarificationManager;
use crate::compactor::ContextCompactor;
use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig};
use crate::cron::TaskManager;
use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::llm::LlmManager;
use crate::mcp::McpManager;
use crate::memory::MemoryManager;
use crate::projects::tickets::ProjectTicketManager;
use crate::run_context::RunContextManager;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
use crate::session::manager::ChatSessionManager;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use super::bundles::{Conversation, Integrations, Media, Models, Tools};
use super::runtime::Runtime;
/// One unlocked user's owner-bound runtime. Lifetime = the pool's lifetime.
pub struct UserContext {
pub user_id: String,
pub pool: Arc<SqlitePool>,
pub event_bus: Arc<ChatEventBus>,
pub sessions: Arc<ChatSessionManager>,
pub chat_hub: Arc<ChatHub>,
pub cron: Arc<TaskManager>,
pub tickets: Arc<ProjectTicketManager>,
pub approval: Arc<ApprovalManager>,
pub clarification: Arc<ClarificationManager>,
pub elicitation: Arc<ElicitationManager>,
pub inbox: Inbox,
/// Per-user server→client push channel. WS handlers subscribe here (via the
/// hub) so a user's `ServerEvent`s never reach another user's socket.
pub global_tx: broadcast::Sender<GlobalEvent>,
}
/// Captures the global capability managers + resolved config once, and stamps out
/// a [`UserContext`] per unlocked pool.
pub(super) struct UserContextFactory {
registry_pool: Arc<SqlitePool>,
llm_manager: Arc<LlmManager>,
tools: Arc<ToolRegistry>,
mcp: Arc<McpManager>,
memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>,
run_context_manager: Arc<RunContextManager>,
system_bus: Arc<SystemEventBus>,
supervisor: Arc<super::supervisor::TaskSupervisor>,
shutdown_token: CancellationToken,
max_history_messages: usize,
max_tool_rounds: usize,
max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig,
compaction: Option<CompactionConfig>,
cron_tz: Option<Tz>,
}
impl UserContextFactory {
pub(super) fn new(
rt: &Runtime,
models: &Models,
media: &Media,
tools: &Tools,
integrations: &Integrations,
conversation: &Conversation,
config: &CoreConfig,
) -> Self {
let cron_tz = config.timezone.as_deref().and_then(|s| s.parse::<Tz>().ok());
Self {
registry_pool: Arc::clone(&rt.db),
llm_manager: Arc::clone(&models.llm_manager),
tools: Arc::clone(&tools.tools),
mcp: Arc::clone(&integrations.mcp),
memory_manager: Arc::clone(&models.memory_manager),
image_generator_manager: Arc::clone(&media.image_generator_manager),
run_context_manager: Arc::clone(&conversation.run_context_manager),
system_bus: Arc::clone(&rt.system_bus),
supervisor: Arc::clone(&rt.supervisor),
shutdown_token: rt.shutdown_token.clone(),
max_history_messages: config.llm.max_history_messages,
max_tool_rounds: config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
max_parallel_subagents: config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS),
max_tool_result_chars: config.llm.max_tool_result_chars,
datetime_config: DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
compaction: config.llm.compaction.clone(),
cron_tz,
}
}
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let pool = Arc::new(pool);
let event_bus = Arc::new(ChatEventBus::new());
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
// Interaction stack, per-user. Approval reads the shared registry rules but
// emits on this user's channel and keeps its own pending map — no cross-user
// collision on request_id / session_id. Rules are seeded once by the global
// ApprovalManager at boot, so no re-seeding here.
let approval = Arc::new(ApprovalManager::new(Arc::clone(&self.registry_pool), global_tx.clone()));
let clarification = ClarificationManager::new(global_tx.clone());
let elicitation = ElicitationManager::new(global_tx.clone());
let inbox = Inbox::new(
Arc::clone(&approval),
Arc::clone(&clarification),
Arc::clone(&elicitation),
Arc::clone(&self.tools),
);
let compactor = self.compaction.as_ref().map(|cfg| {
Arc::new(ContextCompactor::new(
cfg.clone(),
Arc::clone(&self.llm_manager),
Arc::clone(&event_bus),
))
});
let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&pool),
user_id.to_string(),
Arc::clone(&self.llm_manager),
self.max_history_messages,
self.max_tool_rounds,
self.max_parallel_subagents,
self.max_tool_result_chars,
self.datetime_config.clone(),
Arc::clone(&self.tools),
Arc::clone(&self.mcp),
Arc::clone(&approval),
Arc::clone(&clarification),
Arc::clone(&event_bus),
Arc::clone(&self.memory_manager),
Arc::clone(&self.image_generator_manager),
compactor,
Arc::clone(&self.run_context_manager),
// known_tools is registry data → discovery writes to the registry pool.
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
));
let chat_hub = ChatHub::new(
Arc::clone(&pool),
Arc::clone(&manager),
Arc::clone(&approval),
global_tx.clone(),
self.shutdown_token.clone(),
);
chat_hub.register("web").await;
chat_hub.register("talk").await;
let cron = TaskManager::new(Arc::clone(&pool), self.cron_tz, Arc::clone(&self.system_bus));
cron.set_session(Arc::clone(&manager));
cron.set_hub(Arc::clone(&chat_hub));
cron.set_self_arc(Arc::clone(&cron));
chat_hub.set_task_mgr(Arc::clone(&cron));
// Per-user ticket manager — wired to the per-user TaskManager so
// `start_ticket` spawns jobs in the user's own pool.
let tickets = ProjectTicketManager::new(Arc::clone(&pool));
tickets.set_task_manager(Arc::clone(&cron));
// Per-user cron loop. `start()` observes the shutdown token, so it stops on
// shutdown; adopting it lets the supervisor also join it. The name is leaked
// to satisfy the `&'static str` label — bounded by the (small) user count.
let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str());
self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone()));
// Per-user ticket-listener: reacts to JobCompleted events for this user's
// tickets. All users' listeners receive the event (global system bus); only
// the one that owns the ticket does the UPDATE — others no-op on 0 rows.
let tname: &'static str = Box::leak(format!("tickets:{user_id}").into_boxed_str());
self.supervisor.adopt_one(
tname,
Arc::clone(&tickets).start_listener(
Arc::clone(&self.system_bus),
self.shutdown_token.clone(),
),
);
Ok(Arc::new(UserContext {
user_id: user_id.to_string(),
pool,
event_bus,
sessions: manager,
chat_hub,
cron,
tickets,
approval,
clarification,
elicitation,
inbox,
global_tx,
}))
}
}
/// The live per-user contexts, keyed by user id, plus the factory that builds them.
/// A `tokio::Mutex` serialises the build so a context (and its cron loop) is created
/// at most once per user, even under concurrent first-use.
pub(super) struct UserContextRegistry {
factory: UserContextFactory,
contexts: Mutex<HashMap<String, Arc<UserContext>>>,
}
impl UserContextRegistry {
pub(super) fn new(factory: UserContextFactory) -> Self {
Self { factory, contexts: Mutex::new(HashMap::new()) }
}
/// Returns the user's context, building it from `pool` on first use. Idempotent:
/// once built, the same `Arc<UserContext>` is returned until restart.
pub(super) async fn resolve(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let mut guard = self.contexts.lock().await;
if let Some(ctx) = guard.get(user_id) {
return Ok(Arc::clone(ctx));
}
let ctx = self.factory.build(user_id, pool).await?;
guard.insert(user_id.to_string(), Arc::clone(&ctx));
Ok(ctx)
}
}
+17 -38
View File
@@ -1,6 +1,11 @@
//! Post-construction wiring: the `OnceLock` cycle-breakers and the background-task //! Post-construction wiring: the `OnceLock` cycle-breakers and the background-task
//! spawns, each concentrated in one readable place instead of being scattered //! spawns, each concentrated in one readable place instead of being scattered
//! through the constructor. //! through the constructor.
//!
//! Owner-bound background loops (cron, session-cancel, ticket-listener, tic) have
//! moved per-user into `UserContextFactory::build`. What remains here are the
//! instance-wide tasks: LLM-log cleanup on the registry pool, and MCP server
//! initialization.
use std::sync::Arc; use std::sync::Arc;
@@ -14,6 +19,10 @@ use super::runtime::Runtime;
/// Resolves the construction cycles (`cron ↔ session ↔ hub`, `ticket → cron`, /// Resolves the construction cycles (`cron ↔ session ↔ hub`, `ticket → cron`,
/// `mcp → elicitation`) via the managers' `OnceLock` setters. /// `mcp → elicitation`) via the managers' `OnceLock` setters.
///
/// These wire the **global** bundles, which are transitional and will be removed
/// once all call-sites are per-user (Phase 6). They remain constructed so any
/// not-yet-migrated accessor does not panic on a `None` OnceLock.
pub(super) fn wire( pub(super) fn wire(
tasks: &Tasks, tasks: &Tasks,
conversation: &Conversation, conversation: &Conversation,
@@ -29,14 +38,16 @@ pub(super) fn wire(
info!("ChatHub initialised"); info!("ChatHub initialised");
} }
/// Spawns every long-lived background task, each registered by name with the /// Spawns the instance-wide background tasks.
/// supervisor so it is joined on shutdown. MCP `initialize()` is spawned here — ///
/// after `wire()` has installed the elicitation handler — so stdio servers start /// Owner-bound loops (cron, session-cancel, ticket-listener, tic) are **not**
/// with a handler for server-initiated `elicitation/create` requests. /// spawned here — they run per-user inside `UserContext`. Session cancellation is
/// handled directly by the API handlers (which have `AuthUser` and resolve the
/// per-user context). TIC is deferred until connectors return (§13).
pub(super) fn spawn_background( pub(super) fn spawn_background(
rt: &Runtime, rt: &Runtime,
tasks: &Tasks, _tasks: &Tasks,
conversation: &Conversation, _conversation: &Conversation,
integrations: &Integrations, integrations: &Integrations,
config: &CoreConfig, config: &CoreConfig,
) { ) {
@@ -52,29 +63,6 @@ pub(super) fn spawn_background(
); );
} }
// Session-cancellation subscriber: fans SessionCancelled events on the system
// bus into cancel_session() so any in-flight turn / approval / clarification
// all unblock.
{
let manager_ref = Arc::clone(&conversation.manager);
let mut rx = rt.system_bus.subscribe();
let sd = rt.shutdown_token.clone();
rt.supervisor.spawn("session-cancel", async move {
loop {
tokio::select! {
_ = sd.cancelled() => break,
event = rx.recv() => match event {
Ok(core_api::system_bus::SystemEvent::SessionCancelled { session_id }) => {
manager_ref.cancel_session(session_id).await;
}
Ok(_) => {}
Err(_) => break,
}
}
}
});
}
// MCP servers connect in the background. `initialize()` does not itself observe // MCP servers connect in the background. `initialize()` does not itself observe
// the cancellation token, so race it against shutdown: on cancel the task exits // the cancellation token, so race it against shutdown: on cancel the task exits
// promptly (dropping the in-flight connection attempts) instead of blocking the // promptly (dropping the in-flight connection attempts) instead of blocking the
@@ -89,13 +77,4 @@ pub(super) fn spawn_background(
} }
}); });
} }
rt.supervisor.adopt("cron", Arc::clone(&tasks.cron).start(rt.shutdown_token.clone()));
info!("cron scheduler started");
rt.supervisor.adopt_one(
"ticket-listener",
Arc::clone(&tasks.ticket_manager).start_listener(Arc::clone(&rt.system_bus), rt.shutdown_token.clone()),
);
rt.supervisor.adopt_one("tic", Arc::clone(&conversation.tic_manager).start(rt.shutdown_token.clone()));
info!("TicManager started");
} }
+15 -7
View File
@@ -4,7 +4,7 @@ use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::cron::TaskManager; use crate::cron::TaskManager;
use crate::tools::{Tool, ToolDescriptionLength}; use crate::tools::{SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult};
// ── execute_task ────────────────────────────────────────────────────────────── // ── execute_task ──────────────────────────────────────────────────────────────
// //
@@ -134,7 +134,11 @@ pub fn build_execute_task_interface_tool(
// ── delete_cron_job ─────────────────────────────────────────────────────────── // ── delete_cron_job ───────────────────────────────────────────────────────────
pub struct DeleteCronJob(pub Arc<TaskManager>); /// Deleting a job is a plain DELETE on the owner's `scheduled_jobs`, so this tool
/// acts on `ToolContext::pool` (the caller's own database) rather than capturing a
/// globally-scoped `TaskManager` at registration — a job created in a user's own
/// space is deleted from that same space.
pub struct DeleteCronJob;
impl Tool for DeleteCronJob { impl Tool for DeleteCronJob {
fn name(&self) -> &str { "delete_cron_job" } fn name(&self) -> &str { "delete_cron_job" }
@@ -159,12 +163,16 @@ impl Tool for DeleteCronJob {
format!("delete cron job #{id}") format!("delete cron job #{id}")
} }
fn execute(&self, args: Value) -> Result<String> { fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
let pool = Arc::clone(&ctx.pool);
Box::new(SimpleExecution::new(Box::pin(async move {
let id = args["id"].as_i64().ok_or_else(|| anyhow::anyhow!("id must be an integer"))?; let id = args["id"].as_i64().ok_or_else(|| anyhow::anyhow!("id must be an integer"))?;
if self.0.delete_job(id)? { let msg = if crate::db::scheduled_jobs::delete(&pool, id).await? {
Ok(format!("Task {id} deleted.")) format!("Task {id} deleted.")
} else { } else {
Ok(format!("No task with id {id}.")) format!("No task with id {id}.")
} };
Ok(ToolResult::Text(msg))
})))
} }
} }
+7 -3
View File
@@ -55,7 +55,7 @@ use anyhow::Result;
use serde_json::Value; use serde_json::Value;
pub use core_api::tool::{ pub use core_api::tool::{
drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory, drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory, ToolContext,
ToolDescriptionLength, ToolExecution, ToolResult, truncate_label, ToolDescriptionLength, ToolExecution, ToolResult, truncate_label,
}; };
@@ -207,8 +207,12 @@ impl ToolRegistry {
/// Start a [`ToolExecution`] for a registered tool, or `None` if `name` is not /// Start a [`ToolExecution`] for a registered tool, or `None` if `name` is not
/// in the registry (MCP / interface tools are handled by the caller). The /// in the registry (MCP / interface tools are handled by the caller). The
/// returned handle borrows the registry, which outlives the turn. /// returned handle borrows the registry, which outlives the turn.
pub fn run(&self, name: &str, args: Value) -> Option<Box<dyn ToolExecution + '_>> { ///
self.tools.get(name).map(|tool| tool.run(args)) /// `ctx` carries the caller's session id and owner pool: owner-bound tools
/// (e.g. cron management) act on `ctx.pool` instead of a globally-captured
/// manager. Context-free tools ignore it via the default `run_with`.
pub fn run(&self, name: &str, ctx: &ToolContext, args: Value) -> Option<Box<dyn ToolExecution + '_>> {
self.tools.get(name).map(|tool| tool.run_with(ctx, args))
} }
} }
+5
View File
@@ -507,6 +507,11 @@ mod tests {
let system = db::init_system_pool(dir.join("system.db").to_str().unwrap()) let system = db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await .await
.unwrap(); .unwrap();
// Seed the non-admin role some tests use, so the FK on
// users.role_id → roles(id) holds.
db::roles::insert(&system, "children", "Children", "default", None)
.await
.unwrap();
let users = UserManager { let users = UserManager {
system: Arc::new(system), system: Arc::new(system),
+1 -1
View File
@@ -26,7 +26,7 @@ pub async fn get(
) -> Result<Json<AgentDetail>, ApiError> { ) -> Result<Json<AgentDetail>, ApiError> {
let meta = skald_core::agents::load_meta(&id)?; let meta = skald_core::agents::load_meta(&id)?;
let prompt = skald_core::agents::load_prompt(&id)?; let prompt = skald_core::agents::load_prompt(&id)?;
let all = skald.manager().llm_manager().list_models_info().await; let all = skald.llm_manager().list_models_info().await;
let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength); let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength);
Ok(Json(AgentDetail { meta, prompt, models })) Ok(Json(AgentDetail { meta, prompt, models }))
} }
+16 -11
View File
@@ -1,24 +1,25 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use skald_core::approval::NewApprovalRule; use skald_core::approval::NewApprovalRule;
use skald_core::db::approval_rules;
use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo}; use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── GET /api/approval/rules ─────────────────────────────────────────────────── // ── GET /api/approval/rules ───────────────────────────────────────────────────
pub async fn list_rules( pub async fn list_rules(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let rules = skald.approval().list_rules().await?; let rules = approval_rules::list(skald.db()).await?;
Ok(Json(json!(rules))) Ok(Json(json!(rules)))
} }
@@ -28,7 +29,7 @@ pub async fn create_rule(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Json(body): Json<NewApprovalRule>, Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let id = skald.approval().add_rule(body).await?; let id = approval_rules::insert(skald.db(), body).await?;
Ok(Json(json!({ "id": id }))) Ok(Json(json!({ "id": id })))
} }
@@ -42,7 +43,7 @@ pub async fn update_rule(
Path(p): Path<RulePath>, Path(p): Path<RulePath>,
Json(body): Json<NewApprovalRule>, Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.approval().update_rule(p.id, body).await?; approval_rules::update(skald.db(), p.id, body).await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
@@ -52,7 +53,7 @@ pub async fn delete_rule(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>, Path(p): Path<RulePath>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.approval().delete_rule(p.id).await?; approval_rules::delete(skald.db(), p.id).await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
@@ -78,14 +79,16 @@ fn default_action() -> String { "approve".to_string() }
pub async fn resolve_pending( pub async fn resolve_pending(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolvePath>, Path(p): Path<ResolvePath>,
Json(body): Json<ResolveBody>, Json(body): Json<ResolveBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
if body.action == "reject" { if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message. // Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await; ctx.inbox.reject(p.request_id, body.note.clone()).await;
} else { } else {
skald.inbox().approve(p.request_id).await; ctx.inbox.approve(p.request_id).await;
} }
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
} }
@@ -96,9 +99,11 @@ pub async fn resolve_pending(
pub async fn list_pending( pub async fn list_pending(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Json<Value> { Extension(auth): Extension<AuthUser>,
let pending = skald.inbox().list_pending().await.approvals; ) -> Result<Json<Value>, ApiError> {
Json(json!(pending)) let ctx = require_context(&skald, &auth.user_id).await?;
let pending = ctx.inbox.list_pending().await.approvals;
Ok(Json(json!(pending)))
} }
// ── GET /api/approval/tools ─────────────────────────────────────────────────── // ── GET /api/approval/tools ───────────────────────────────────────────────────
+32 -15
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
@@ -8,7 +8,7 @@ use serde::Deserialize;
use skald_core::db::{scheduled_jobs, job_runs}; use skald_core::db::{scheduled_jobs, job_runs};
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct JobResponse { pub struct JobResponse {
@@ -29,8 +29,12 @@ pub struct JobResponse {
pub running_since: Option<String>, pub running_since: Option<String>,
} }
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobResponse>>, ApiError> { pub async fn list(
let jobs = scheduled_jobs::list(skald.db()).await?; State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<JobResponse>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let jobs = scheduled_jobs::list(&ctx.pool).await?;
Ok(Json(jobs.into_iter().map(|j| JobResponse { Ok(Json(jobs.into_iter().map(|j| JobResponse {
id: j.id, id: j.id,
title: j.title, title: j.title,
@@ -51,22 +55,26 @@ pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobRespons
} }
pub async fn delete_job( pub async fn delete_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let found = scheduled_jobs::delete(skald.db(), id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let found = scheduled_jobs::delete(&ctx.pool, id).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
pub async fn toggle( pub async fn toggle(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<serde_json::Value>, Json(body): Json<serde_json::Value>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let enabled = body["enabled"] let enabled = body["enabled"]
.as_bool() .as_bool()
.ok_or_else(|| ApiError::bad_request("'enabled' boolean required"))?; .ok_or_else(|| ApiError::bad_request("'enabled' boolean required"))?;
let found = scheduled_jobs::set_enabled(skald.db(), id, enabled).await?; let found = scheduled_jobs::set_enabled(&ctx.pool, id, enabled).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
@@ -76,15 +84,17 @@ pub struct SetRunContextBody {
} }
pub async fn set_run_context( pub async fn set_run_context(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<SetRunContextBody>, Json(body): Json<SetRunContextBody>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
use skald_core::run_context::RunContext; use skald_core::run_context::RunContext;
let json = body.security_group.as_ref().map(|sg| { let json = body.security_group.as_ref().map(|sg| {
RunContext::with_security_group(Some(sg.clone())).to_db() RunContext::with_security_group(Some(sg.clone())).to_db()
}); });
let found = scheduled_jobs::set_run_context(skald.db(), id, json.as_deref()).await?; let found = scheduled_jobs::set_run_context(&ctx.pool, id, json.as_deref()).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
@@ -106,19 +116,26 @@ pub struct JobRunResponse {
} }
pub async fn kill_job( pub async fn kill_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let job = scheduled_jobs::get_by_id(skald.db(), id).await? let ctx = require_context(&skald, &auth.user_id).await?;
let job = scheduled_jobs::get_by_id(&ctx.pool, id).await?
.ok_or_else(|| ApiError::not_found(format!("job {id} not found")))?; .ok_or_else(|| ApiError::not_found(format!("job {id} not found")))?;
let session_id = job.running_session_id let session_id = job.running_session_id
.ok_or_else(|| ApiError::bad_request("job is not currently running"))?; .ok_or_else(|| ApiError::bad_request("job is not currently running"))?;
skald.system_bus().send(core_api::system_bus::SystemEvent::SessionCancelled { session_id }); // Direct cancel on the user's own session manager — no system bus fan-out.
ctx.sessions.cancel_session(session_id).await;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
pub async fn list_runs(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobRunResponse>>, ApiError> { pub async fn list_runs(
let runs = job_runs::list_all(skald.db(), 200).await?; State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<JobRunResponse>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let runs = job_runs::list_all(&ctx.pool, 200).await?;
Ok(Json(runs.into_iter().map(|r| JobRunResponse { Ok(Json(runs.into_iter().map(|r| JobRunResponse {
id: r.id, id: r.id,
job_id: r.job_id, job_id: r.job_id,
+68 -63
View File
@@ -1,13 +1,13 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
response::IntoResponse, response::IntoResponse,
Json, Json, Extension,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
const KEY: &str = "DEBUG_MODE"; const KEY: &str = "DEBUG_MODE";
@@ -44,8 +44,6 @@ const PAGE_SIZE: i64 = 20;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LlmRequestsQuery { pub struct LlmRequestsQuery {
pub agent_id: Option<String>,
pub source: Option<String>,
pub from: Option<String>, pub from: Option<String>,
pub to: Option<String>, pub to: Option<String>,
pub page: Option<i64>, pub page: Option<i64>,
@@ -54,8 +52,6 @@ pub struct LlmRequestsQuery {
#[derive(Serialize)] #[derive(Serialize)]
pub struct LlmRequestItem { pub struct LlmRequestItem {
pub id: i64, pub id: i64,
pub agent_id: Option<String>,
pub source: Option<String>,
pub model_name: String, pub model_name: String,
pub created_at: String, pub created_at: String,
pub input_tokens: Option<i64>, pub input_tokens: Option<i64>,
@@ -76,37 +72,34 @@ pub struct LlmRequestsResponse {
pub async fn list_llm_requests( pub async fn list_llm_requests(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(params): Query<LlmRequestsQuery>, Query(params): Query<LlmRequestsQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let page = params.page.unwrap_or(1).max(1); let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * PAGE_SIZE; let offset = (page - 1) * PAGE_SIZE;
// Bind optional filters twice each: once for the IS NULL check, once for the // Metadata-only query on system.db. No JOIN with chat_sessions (that table
// equality check. SQLite evaluates `? IS NULL` against the bound value itself. // lives in the per-user owner bucket). Filters by user_id so each user sees
let items = sqlx::query_as::<_, (i64, Option<String>, Option<String>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>( // only their own requests.
let items = sqlx::query_as::<_, (i64, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>(
"SELECT "SELECT
r.id, id,
s.agent_id, model_name,
s.source, created_at,
r.model_name, input_tokens,
r.created_at, output_tokens,
r.input_tokens, cache_read_tokens,
r.output_tokens, cache_creation_tokens,
r.cache_read_tokens, duration_ms,
r.cache_creation_tokens, error_text
r.duration_ms, FROM llm_requests
r.error_text WHERE user_id = ?
FROM llm_requests r AND (? IS NULL OR created_at >= ?)
LEFT JOIN chat_sessions s ON s.id = r.session_id AND (? IS NULL OR created_at <= ?)
WHERE (? IS NULL OR s.agent_id = ?) ORDER BY created_at DESC
AND (? IS NULL OR s.source = ?)
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?",
) )
.bind(&params.agent_id).bind(&params.agent_id) .bind(&auth.user_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from) .bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to) .bind(&params.to).bind(&params.to)
.bind(PAGE_SIZE) .bind(PAGE_SIZE)
@@ -114,22 +107,19 @@ pub async fn list_llm_requests(
.fetch_all(&**skald.db()) .fetch_all(&**skald.db())
.await? .await?
.into_iter() .into_iter()
.map(|(id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)| { .map(|(id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)| {
LlmRequestItem { id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text } LlmRequestItem { id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let total = sqlx::query_scalar::<_, i64>( let total = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM llm_requests r FROM llm_requests
LEFT JOIN chat_sessions s ON s.id = r.session_id WHERE user_id = ?
WHERE (? IS NULL OR s.agent_id = ?) AND (? IS NULL OR created_at >= ?)
AND (? IS NULL OR s.source = ?) AND (? IS NULL OR created_at <= ?)",
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)",
) )
.bind(&params.agent_id).bind(&params.agent_id) .bind(&auth.user_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from) .bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to) .bind(&params.to).bind(&params.to)
.fetch_one(&**skald.db()) .fetch_one(&**skald.db())
@@ -143,8 +133,7 @@ pub async fn list_llm_requests(
#[derive(Serialize)] #[derive(Serialize)]
pub struct LlmRequestDetail { pub struct LlmRequestDetail {
pub id: i64, pub id: i64,
pub agent_id: Option<String>, pub request_id: Option<String>,
pub source: Option<String>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
pub model_name: String, pub model_name: String,
pub created_at: String, pub created_at: String,
@@ -154,6 +143,8 @@ pub struct LlmRequestDetail {
pub cache_creation_tokens: Option<i64>, pub cache_creation_tokens: Option<i64>,
pub duration_ms: i64, pub duration_ms: i64,
pub error_text: Option<String>, pub error_text: Option<String>,
// Payload fields — now live in llm_request_payloads in the user's own database.
// Left as None for now; a future cross-pool lookup via request_id can fill them.
pub request_json: Option<String>, pub request_json: Option<String>,
pub request_headers: Option<String>, pub request_headers: Option<String>,
pub response_json: Option<String>, pub response_json: Option<String>,
@@ -162,40 +153,54 @@ pub struct LlmRequestDetail {
pub async fn get_llm_request( pub async fn get_llm_request(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let row = sqlx::query_as::<_, (i64, Option<String>, Option<String>, Option<i64>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>( let row = sqlx::query_as::<_, (i64, Option<String>, Option<i64>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>(
"SELECT "SELECT
r.id, id,
s.agent_id, request_id,
s.source, stack_id,
r.stack_id, model_name,
r.model_name, created_at,
r.created_at, input_tokens,
r.input_tokens, output_tokens,
r.output_tokens, cache_read_tokens,
r.cache_read_tokens, cache_creation_tokens,
r.cache_creation_tokens, duration_ms,
r.duration_ms, error_text
r.error_text, FROM llm_requests
NULLIF(r.request_json, '') AS request_json, WHERE id = ? AND user_id = ?",
r.request_headers,
r.response_json,
r.response_headers
FROM llm_requests r
LEFT JOIN chat_sessions s ON s.id = r.session_id
WHERE r.id = ?",
) )
.bind(id) .bind(id)
.bind(&auth.user_id)
.fetch_optional(&**skald.db()) .fetch_optional(&**skald.db())
.await?; .await?;
let Some((id, agent_id, source, stack_id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text, request_json, request_headers, response_json, response_headers)) = row else { let Some((id, request_id, stack_id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)) = row else {
return Err(ApiError::not_found(format!("llm_request {id} not found"))); return Err(ApiError::not_found(format!("llm_request {id} not found")));
}; };
// Try to fetch the payload from the user's own database via request_id.
let payload = if let Some(ref rid) = request_id {
let ctx = require_context(&skald, &auth.user_id).await.ok();
if let Some(ctx) = ctx {
sqlx::query_as::<_, (Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT request_json, request_headers, response_json, response_headers
FROM llm_request_payloads WHERE request_id = ?",
)
.bind(rid)
.fetch_optional(&*ctx.pool)
.await
.ok()
.flatten()
} else { None }
} else { None };
let (request_json, request_headers, response_json, response_headers) = payload.unwrap_or((None, None, None, None));
Ok(Json(LlmRequestDetail { Ok(Json(LlmRequestDetail {
id, agent_id, source, stack_id, model_name, created_at, id, request_id, stack_id, model_name, created_at,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
duration_ms, error_text, duration_ms, error_text,
request_json, request_headers, response_json, response_headers, request_json, request_headers, response_json, response_headers,
+30 -20
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -7,22 +7,26 @@ use serde_json::{Value, json};
use std::time::Duration; use std::time::Duration;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::{Skald, UserContext};
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── GET /api/inbox ──────────────────────────────────────────────────────────── // ── GET /api/inbox ────────────────────────────────────────────────────────────
// //
// Returns all pending approval requests and clarification requests in a single // Returns all pending approval requests and clarification requests in a single
// response, so the frontend can show a unified Agent Inbox page with one fetch. // response, so the frontend can show a unified Agent Inbox page with one fetch.
pub async fn list(State(skald): State<Arc<Skald>>) -> Json<Value> { pub async fn list(
let items = skald.inbox().list_pending().await; State(skald): State<Arc<Skald>>,
Json(json!({ Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let items = ctx.inbox.list_pending().await;
Ok(Json(json!({
"total": items.total, "total": items.total,
"approvals": items.approvals, "approvals": items.approvals,
"clarifications": items.clarifications, "clarifications": items.clarifications,
"elicitations": items.elicitations, "elicitations": items.elicitations,
})) })))
} }
// ── POST /api/inbox/approvals/:request_id/resolve ───────────────────────────── // ── POST /api/inbox/approvals/:request_id/resolve ─────────────────────────────
@@ -47,17 +51,19 @@ fn default_action() -> String { "approve".to_string() }
pub async fn resolve_approval( pub async fn resolve_approval(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ApprovePath>, Path(p): Path<ApprovePath>,
Json(body): Json<ApproveBody>, Json(body): Json<ApproveBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Peek info before resolving so we have session_id and tool metadata for bypass. // Peek info before resolving so we have session_id and tool metadata for bypass.
let info = skald.approval().get_pending(p.request_id).await; let info = ctx.approval.get_pending(p.request_id).await;
if body.action == "reject" { if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message. // Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await; ctx.inbox.reject(p.request_id, body.note.clone()).await;
} else { } else {
skald.inbox().approve(p.request_id).await; ctx.inbox.approve(p.request_id).await;
// Apply bypass if requested (only on approve). // Apply bypass if requested (only on approve).
if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) { if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) {
@@ -72,29 +78,29 @@ pub async fn resolve_approval(
match scope { match scope {
"category" => { "category" => {
if let Some(cat) = info.tool_category { if let Some(cat) = info.tool_category {
skald.approval().bypass_session_for_category(info.session_id, cat, duration).await; ctx.approval.bypass_session_for_category(info.session_id, cat, duration).await;
} else { } else {
apply_all_bypass(&skald, info.session_id, duration).await; apply_all_bypass(&ctx, info.session_id, duration).await;
} }
} }
"mcp_server" => { "mcp_server" => {
if let Some(server) = info.mcp_server { if let Some(server) = info.mcp_server {
skald.approval().bypass_session_for_mcp(info.session_id, server, duration).await; ctx.approval.bypass_session_for_mcp(info.session_id, server, duration).await;
} else { } else {
apply_all_bypass(&skald, info.session_id, duration).await; apply_all_bypass(&ctx, info.session_id, duration).await;
} }
} }
_ => apply_all_bypass(&skald, info.session_id, duration).await, _ => apply_all_bypass(&ctx, info.session_id, duration).await,
} }
} }
} }
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
} }
async fn apply_all_bypass(skald: &Skald, session_id: i64, duration: Option<Duration>) { async fn apply_all_bypass(ctx: &UserContext, session_id: i64, duration: Option<Duration>) {
match duration { match duration {
Some(d) => skald.approval().bypass_session_for(session_id, d).await, Some(d) => ctx.approval.bypass_session_for(session_id, d).await,
None => skald.approval().bypass_session(session_id).await, None => ctx.approval.bypass_session(session_id).await,
} }
} }
@@ -110,13 +116,15 @@ pub struct ClarifyBody {
pub async fn resolve_clarification( pub async fn resolve_clarification(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ClarifyPath>, Path(p): Path<ClarifyPath>,
Json(body): Json<ClarifyBody>, Json(body): Json<ClarifyBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
if body.answer.trim().is_empty() { if body.answer.trim().is_empty() {
return Err(ApiError::bad_request("answer must not be empty")); return Err(ApiError::bad_request("answer must not be empty"));
} }
let resolved = skald.inbox().answer(p.request_id, body.answer).await; let resolved = ctx.inbox.answer(p.request_id, body.answer).await;
if resolved { if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id })))
} else { } else {
@@ -145,14 +153,16 @@ fn default_elicit_action() -> String { "decline".to_string() }
pub async fn resolve_elicitation( pub async fn resolve_elicitation(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ElicitPath>, Path(p): Path<ElicitPath>,
Json(body): Json<ElicitBody>, Json(body): Json<ElicitBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let action = match body.action.as_str() { let action = match body.action.as_str() {
"accept" | "decline" | "cancel" => body.action.clone(), "accept" | "decline" | "cancel" => body.action.clone(),
other => return Err(ApiError::bad_request(format!("invalid action: {other}"))), other => return Err(ApiError::bad_request(format!("invalid action: {other}"))),
}; };
let resolved = skald.inbox().resolve_elicitation(p.request_id, action.clone(), body.content).await; let resolved = ctx.inbox.resolve_elicitation(p.request_id, action.clone(), body.content).await;
if resolved { if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action })))
} else { } else {
+13 -13
View File
@@ -17,7 +17,7 @@ pub async fn provider_models(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<Vec<RemoteLlmModelInfo>>, ApiError> { ) -> Result<Json<Vec<RemoteLlmModelInfo>>, ApiError> {
let models = skald.manager().llm_manager().list_provider_models(id).await?; let models = skald.llm_manager().list_provider_models(id).await?;
Ok(Json(models)) Ok(Json(models))
} }
@@ -36,7 +36,7 @@ pub async fn provider_reasoning_mode(
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
axum::extract::Query(q): axum::extract::Query<ReasoningModeQuery>, axum::extract::Query(q): axum::extract::Query<ReasoningModeQuery>,
) -> Json<Option<ReasoningMode>> { ) -> Json<Option<ReasoningMode>> {
let mode = skald.manager().llm_manager().reasoning_mode_for(id, &q.model_id).await; let mode = skald.llm_manager().reasoning_mode_for(id, &q.model_id).await;
Json(mode) Json(mode)
} }
@@ -51,7 +51,7 @@ pub struct SelectorResponse {
pub async fn selector( pub async fn selector(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<SelectorResponse>, ApiError> { ) -> Result<Json<SelectorResponse>, ApiError> {
let mgr = skald.manager().llm_manager(); let mgr = skald.llm_manager();
let models = mgr.client_names().await; let models = mgr.client_names().await;
let default = mgr.default_name().await; let default = mgr.default_name().await;
Ok(Json(SelectorResponse { models, default })) Ok(Json(SelectorResponse { models, default }))
@@ -62,7 +62,7 @@ pub async fn selector(
pub async fn list_providers( pub async fn list_providers(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmProviderInfo>>, ApiError> { ) -> Result<Json<Vec<LlmProviderInfo>>, ApiError> {
Ok(Json(skald.manager().llm_manager().list_providers_info().await)) Ok(Json(skald.llm_manager().list_providers_info().await))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -94,7 +94,7 @@ pub async fn create_provider(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?; validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload); let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().add_provider(record).await?; skald.llm_manager().add_provider(record).await?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -102,7 +102,7 @@ pub async fn get_provider(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmProviderRecord>, ApiError> { ) -> Result<Json<LlmProviderRecord>, ApiError> {
skald.manager().llm_manager().get_provider(id).await skald.llm_manager().get_provider(id).await
.map(Json) .map(Json)
.ok_or_else(|| ApiError::not_found(format!("provider {id} not found"))) .ok_or_else(|| ApiError::not_found(format!("provider {id} not found")))
} }
@@ -114,7 +114,7 @@ pub async fn update_provider(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?; validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload); let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().update_provider(id, record).await?; skald.llm_manager().update_provider(id, record).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -122,7 +122,7 @@ pub async fn delete_provider(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_provider(id).await?; skald.llm_manager().delete_provider(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -131,7 +131,7 @@ pub async fn delete_provider(
pub async fn list_models( pub async fn list_models(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmModelInfo>>, ApiError> { ) -> Result<Json<Vec<LlmModelInfo>>, ApiError> {
let mgr = skald.manager().llm_manager(); let mgr = skald.llm_manager();
// Warm the catalog cache for every provider concurrently so that price data // Warm the catalog cache for every provider concurrently so that price data
// is available for the join inside list_models_info(). Errors are ignored — // is available for the join inside list_models_info(). Errors are ignored —
@@ -203,7 +203,7 @@ pub async fn create_model(
Json(payload): Json<ModelPayload>, Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?; let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().add_model(record).await?; skald.llm_manager().add_model(record).await?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -211,7 +211,7 @@ pub async fn get_model(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmModelRecord>, ApiError> { ) -> Result<Json<LlmModelRecord>, ApiError> {
skald.manager().llm_manager().get_model(id).await skald.llm_manager().get_model(id).await
.map(Json) .map(Json)
.ok_or_else(|| ApiError::not_found(format!("model {id} not found"))) .ok_or_else(|| ApiError::not_found(format!("model {id} not found")))
} }
@@ -222,7 +222,7 @@ pub async fn update_model(
Json(payload): Json<ModelPayload>, Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?; let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().update_model(id, record).await?; skald.llm_manager().update_model(id, record).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -230,7 +230,7 @@ pub async fn delete_model(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_model(id).await?; skald.llm_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+16 -9
View File
@@ -139,8 +139,6 @@ pub fn router() -> Router<Arc<Skald>> {
// Config properties // Config properties
.route("/config", get(config::list_properties)) .route("/config", get(config::list_properties))
.route("/config/{key}", put(config::set_property)) .route("/config/{key}", put(config::set_property))
// TIC
.route("/tic/trigger", post(tic_trigger))
// Plugins // Plugins
.route("/plugins", get(plugins::list)) .route("/plugins", get(plugins::list))
.route("/plugins/{id}", put(plugins::update)) .route("/plugins/{id}", put(plugins::update))
@@ -164,13 +162,6 @@ pub fn router() -> Router<Arc<Skald>> {
.route("/file", delete(files::delete_file)) .route("/file", delete(files::delete_file))
} }
async fn tic_trigger(State(skald): State<Arc<Skald>>) -> impl IntoResponse {
tokio::spawn(async move {
Arc::clone(skald.tic_manager()).tick_now().await;
});
StatusCode::ACCEPTED
}
pub struct ApiError { pub struct ApiError {
status: StatusCode, status: StatusCode,
message: String, message: String,
@@ -184,6 +175,22 @@ impl ApiError {
pub fn not_found(msg: impl Into<String>) -> Self { pub fn not_found(msg: impl Into<String>) -> Self {
Self { status: StatusCode::NOT_FOUND, message: msg.into() } Self { status: StatusCode::NOT_FOUND, message: msg.into() }
} }
pub fn unauthorized(msg: impl Into<String>) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: msg.into() }
}
}
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
/// user's database is locked (not logged in). Every owner-bound HTTP handler starts
/// here to route reads/writes to the caller's own `{userid}.db` instead of the
/// shared registry pool. The `AuthUser` is injected by [`guard::require_auth`].
pub async fn require_context(
skald: &Skald,
user_id: &str,
) -> Result<Arc<skald_core::skald::UserContext>, ApiError> {
skald.user_context(user_id).await
.ok_or_else(|| ApiError::unauthorized("session expired — please log in again"))
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
+93 -40
View File
@@ -1,17 +1,19 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use skald_core::db::project_tickets::ProjectTicket; use skald_core::db::project_tickets::ProjectTicket;
use skald_core::db::projects::Project; use skald_core::db::projects::Project;
use skald_core::db::{project_tickets, projects};
use skald_core::run_context::RunContext; use skald_core::run_context::RunContext;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
/// Source-id prefix for a project's interactive chat session (e.g. `project-42`). /// Source-id prefix for a project's interactive chat session (e.g. `project-42`).
/// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`. /// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`.
@@ -130,62 +132,70 @@ impl<'de> Deserialize<'de> for TicketPath {
pub async fn list( pub async fn list(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<ProjectResponse>>, ApiError> { ) -> Result<Json<Vec<ProjectResponse>>, ApiError> {
let projects = skald.projects().list().await?; let ctx = require_context(&skald, &auth.user_id).await?;
Ok(Json(projects.into_iter().map(Into::into).collect())) let items = projects::list(&ctx.pool).await?;
Ok(Json(items.into_iter().map(Into::into).collect()))
} }
pub async fn create( pub async fn create(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<ProjectBody>, Json(body): Json<ProjectBody>,
) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> { ) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db); let project = projects::create(
let project = skald.projects().create( &ctx.pool,
&body.name, &body.name,
&body.path, &body.path,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
Ok((StatusCode::CREATED, Json(project.into()))) Ok((StatusCode::CREATED, Json(project.into())))
} }
pub async fn get_project( pub async fn get_project(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<Json<ProjectResponse>, ApiError> { ) -> Result<Json<ProjectResponse>, ApiError> {
let project = skald.projects().get(p.id).await? let ctx = require_context(&skald, &auth.user_id).await?;
let project = projects::get(&ctx.pool, p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into())) Ok(Json(project.into()))
} }
pub async fn update( pub async fn update(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
Json(body): Json<ProjectBody>, Json(body): Json<ProjectBody>,
) -> Result<Json<ProjectResponse>, ApiError> { ) -> Result<Json<ProjectResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db); let found = projects::update(
let found = skald.projects().update( &ctx.pool, p.id,
p.id, &body.name, &body.path,
&body.name,
&body.path,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
if !found { if !found {
return Err(ApiError::not_found(format!("project {} not found", p.id))); return Err(ApiError::not_found(format!("project {} not found", p.id)));
} }
let project = skald.projects().get(p.id).await? let project = projects::get(&ctx.pool, p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into())) Ok(Json(project.into()))
} }
pub async fn delete( pub async fn delete(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let found = skald.projects().delete(p.id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let found = projects::delete(&ctx.pool, p.id).await?;
if found { Ok(StatusCode::NO_CONTENT) } if found { Ok(StatusCode::NO_CONTENT) }
else { Err(ApiError::not_found(format!("project {} not found", p.id))) } else { Err(ApiError::not_found(format!("project {} not found", p.id))) }
} }
@@ -193,56 +203,97 @@ pub async fn delete(
// ── Ticket handlers ─────────────────────────────────────────────────────────── // ── Ticket handlers ───────────────────────────────────────────────────────────
pub async fn list_tickets( pub async fn list_tickets(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<Json<Vec<TicketResponse>>, ApiError> { ) -> Result<Json<Vec<TicketResponse>>, ApiError> {
let tickets = skald.ticket_manager().list(p.id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let tickets = project_tickets::list_for_project(&ctx.pool, p.id).await?;
Ok(Json(tickets.into_iter().map(Into::into).collect())) Ok(Json(tickets.into_iter().map(Into::into).collect()))
} }
pub async fn create_ticket( pub async fn create_ticket(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
Json(body): Json<TicketBody>, Json(body): Json<TicketBody>,
) -> Result<(StatusCode, Json<TicketResponse>), ApiError> { ) -> Result<(StatusCode, Json<TicketResponse>), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db);
// Tickets run a task sub-agent — no default. The agent's `type == task` is enforced
// when the ticket starts, via TaskManager::spawn_async_job (require_task_agent).
let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?; .ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?;
let ticket = skald.ticket_manager().create( let ticket = project_tickets::create(
p.id, &ctx.pool, p.id,
&body.title, &body.title,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
agent_id, agent_id,
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
projects::touch(&ctx.pool, p.id).await?;
Ok((StatusCode::CREATED, Json(ticket.into()))) Ok((StatusCode::CREATED, Json(ticket.into())))
} }
pub async fn delete_ticket( pub async fn delete_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let found = skald.ticket_manager().delete(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
if found { Ok(StatusCode::NO_CONTENT) } let ticket = project_tickets::get(&ctx.pool, tp.tid).await?;
else { Err(ApiError::not_found(format!("ticket {} not found", tp.tid))) } let found = project_tickets::delete(&ctx.pool, tp.tid).await?;
if found {
if let Some(t) = ticket {
projects::touch(&ctx.pool, t.project_id).await?;
}
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found(format!("ticket {} not found", tp.tid)))
}
} }
pub async fn start_ticket( pub async fn start_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.ticket_manager().start(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let ticket = project_tickets::get(&ctx.pool, tp.tid).await?
.ok_or_else(|| ApiError::not_found(format!("ticket {} not found", tp.tid)))?;
let project = projects::get(&ctx.pool, ticket.project_id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", ticket.project_id)))?;
let base: Option<RunContext> =
ticket.run_context.as_deref().and_then(RunContext::from_db)
.or_else(|| project.run_context.as_deref().and_then(RunContext::from_db));
let rc = skald_core::projects::build_runtime_run_context(&project, base);
let origin_ref = format!("PROJECT_TASK:{}", tp.tid);
let rc_json = rc.to_db();
let job = ctx.cron.spawn_async_job(
&ticket.title,
&ticket.description,
&ticket.description,
&ticket.agent_id,
Some(&rc_json),
&origin_ref,
)?;
project_tickets::start(&ctx.pool, tp.tid, job.id).await?;
projects::touch(&ctx.pool, ticket.project_id).await?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
pub async fn reset_ticket( pub async fn reset_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.ticket_manager().reset(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let project_id = project_tickets::get(&ctx.pool, tp.tid).await?.map(|t| t.project_id);
project_tickets::reset(&ctx.pool, tp.tid).await?;
if let Some(pid) = project_id {
projects::touch(&ctx.pool, pid).await?;
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -261,7 +312,7 @@ pub struct SessionResponse {
/// provisioning config, shared by session-open and session-reset so the two never /// provisioning config, shared by session-open and session-reset so the two never
/// diverge. /// diverge.
pub async fn provisioning_for_source( pub async fn provisioning_for_source(
skald: &Skald, pool: &SqlitePool,
source: &str, source: &str,
) -> Result<(String, Option<RunContext>), ApiError> { ) -> Result<(String, Option<RunContext>), ApiError> {
let Some(id) = source let Some(id) = source
@@ -271,7 +322,7 @@ pub async fn provisioning_for_source(
return Ok(("main".to_string(), None)); return Ok(("main".to_string(), None));
}; };
let project = skald.projects().get(id).await? let project = projects::get(pool, id).await?
.ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?; .ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?;
let base = project.run_context.as_deref().and_then(RunContext::from_db); let base = project.run_context.as_deref().and_then(RunContext::from_db);
let rc = skald_core::projects::build_runtime_run_context(&project, base); let rc = skald_core::projects::build_runtime_run_context(&project, base);
@@ -284,10 +335,12 @@ pub async fn provisioning_for_source(
pub async fn open_session( pub async fn open_session(
Path(p): Path<ProjectPath>, Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<SessionResponse>, ApiError> { ) -> Result<Json<SessionResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id); let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id);
let (agent, rc) = provisioning_for_source(&skald, &source).await?; let (agent, rc) = provisioning_for_source(&ctx.pool, &source).await?;
let session_id = skald.chat_hub() let session_id = ctx.chat_hub
.provision_session(&source, &agent, rc.as_ref(), false) .provision_session(&source, &agent, rc.as_ref(), false)
.await?; .await?;
Ok(Json(SessionResponse { source, session_id })) Ok(Json(SessionResponse { source, session_id }))
+13 -4
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
@@ -9,7 +9,7 @@ use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── Tool Permission Groups ──────────────────────────────────────────────────── // ── Tool Permission Groups ────────────────────────────────────────────────────
@@ -87,12 +87,21 @@ pub struct SessionPath { pub session_id: i64 }
/// POST body: the full RunContext object, or JSON `null` to clear the context. /// POST body: the full RunContext object, or JSON `null` to clear the context.
pub async fn set_session_run_context( pub async fn set_session_run_context(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SessionPath>, Path(p): Path<SessionPath>,
Json(ctx): Json<Option<skald_core::run_context::RunContext>>, Json(ctx): Json<Option<skald_core::run_context::RunContext>>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.run_context_manager().set_session_run_context(p.session_id, ctx.as_ref()).await?; let uctx = require_context(&skald, &auth.user_id).await?;
// The session row (and its live handler) live in the caller's own pool, so the
// persist + live update both target the user's context. Run-context *definitions*
// (roles) remain instance-wide; only the per-session value is owner data.
skald_core::db::chat_sessions::set_run_context(
&uctx.pool,
p.session_id,
ctx.as_ref().map(|c| c.to_db()).as_deref(),
).await?;
if let Some(handler) = skald.manager().active_handler(p.session_id).await { if let Some(handler) = uctx.sessions.active_handler(p.session_id).await {
handler.set_run_context(ctx).await; handler.set_run_context(ctx).await;
} }
+46 -28
View File
@@ -3,7 +3,7 @@ use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, Query, State}, extract::{Path, Query, State},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -13,12 +13,12 @@ use sqlx::SqlitePool;
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
use skald_core::db::chat_sessions_stack::SessionStack; use skald_core::db::chat_sessions_stack::SessionStack;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::{Skald, UserContext};
use skald_core::session::handler::ApprovalDecision; use skald_core::session::handler::ApprovalDecision;
use skald_core::approval::ApprovalManager; use skald_core::approval::ApprovalManager;
use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── POST /api/sessions — start a new conversation ───────────────────────────── // ── POST /api/sessions — start a new conversation ─────────────────────────────
@@ -32,12 +32,14 @@ fn default_source() -> String { "web".to_string() }
pub async fn create( pub async fn create(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<CreateQuery>, Query(q): Query<CreateQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve agent + RunContext from the source so project chats reset with the // Resolve agent + RunContext from the source so project chats reset with the
// coordinator agent (not the default `main`), then provision a fresh session. // coordinator agent (not the default `main`), then provision a fresh session.
let (agent, rc) = super::projects::provisioning_for_source(&skald, &q.source).await?; let (agent, rc) = super::projects::provisioning_for_source(&ctx.pool, &q.source).await?;
skald.chat_hub().provision_session(&q.source, &agent, rc.as_ref(), true).await?; ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
@@ -45,8 +47,10 @@ pub async fn create(
pub async fn web_messages( pub async fn web_messages(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<Value>>, ApiError> { ) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, "web").await let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, "web").await
} }
// ── GET /api/:source/messages ───────────────────────────────────────────────── // ── GET /api/:source/messages ─────────────────────────────────────────────────
@@ -56,31 +60,36 @@ pub struct SourcePath { pub source: String }
pub async fn source_messages( pub async fn source_messages(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SourcePath>, Path(p): Path<SourcePath>,
) -> Result<Json<Vec<Value>>, ApiError> { ) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, &p.source).await let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, &p.source).await
} }
async fn messages_for_source(skald: &Arc<Skald>, source: &str) -> Result<Json<Vec<Value>>, ApiError> { async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str) -> Result<Json<Vec<Value>>, ApiError> {
let session_id = match sources::active_session_id(skald.db(), source).await? { // History/sessions read from the caller's own pool; the tool registry is a
// global capability, and approval is this user's per-user manager.
let db = &ctx.pool;
let session_id = match sources::active_session_id(db, source).await? {
Some(id) => id, Some(id) => id,
None => return Ok(Json(vec![])), None => return Ok(Json(vec![])),
}; };
let main_stack = match chat_sessions_stack::main_for_session(skald.db(), session_id).await? { let main_stack = match chat_sessions_stack::main_for_session(db, session_id).await? {
Some(s) => s, Some(s) => s,
None => return Ok(Json(vec![])), None => return Ok(Json(vec![])),
}; };
let subagent_map: HashMap<i64, SessionStack> = let subagent_map: HashMap<i64, SessionStack> =
chat_sessions_stack::all_for_session(skald.db(), session_id) chat_sessions_stack::all_for_session(db, session_id)
.await? .await?
.into_iter() .into_iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s))) .filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s)))
.collect(); .collect();
let mut items: Vec<Value> = Vec::new(); let mut items: Vec<Value> = Vec::new();
build_items(skald.db(), skald.tools(), skald.approval(), &main_stack, &subagent_map, &mut items).await?; build_items(db, skald.tools(), &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items)) Ok(Json(items))
} }
@@ -117,9 +126,12 @@ pub struct ResolveToolResponse {
/// resolve against the correct session — there is no "current session" scoping. /// resolve against the correct session — there is no "current session" scoping.
pub async fn resolve_tool( pub async fn resolve_tool(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>, Path(p): Path<ResolveToolPath>,
Json(body): Json<ResolveToolBody>, Json(body): Json<ResolveToolBody>,
) -> Result<Json<ResolveToolResponse>, ApiError> { ) -> Result<Json<ResolveToolResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
// Look up the tool call by id alone — no active-session filter. Also pull the // Look up the tool call by id alone — no active-session filter. Also pull the
// owning session_id so the post-restart path drives the correct session. // owning session_id so the post-restart path drives the correct session.
let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>( let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>(
@@ -130,7 +142,7 @@ pub async fn resolve_tool(
WHERE t.id = ?", WHERE t.id = ?",
) )
.bind(p.tool_call_id) .bind(p.tool_call_id)
.fetch_optional(&**skald.db()) .fetch_optional(&**db)
.await? .await?
.ok_or_else(|| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"tool_call_id {} not found", p.tool_call_id "tool_call_id {} not found", p.tool_call_id
@@ -152,12 +164,12 @@ pub async fn resolve_tool(
// Pass the raw user note to the live session so the loop builds the // Pass the raw user note to the live session so the loop builds the
// canonical message; for the not-live path (no waiting session, e.g. // canonical message; for the not-live path (no waiting session, e.g.
// after a restart) build the same message here and save it directly. // after a restart) build the same message here and save it directly.
let live = skald.approval() let live = ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() }) .resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() })
.await; .await;
let msg = ApprovalDecision::rejection_message(&body.note); let msg = ApprovalDecision::rejection_message(&body.note);
if !live { if !live {
chat_llm_tools::reject(skald.db(), tc_id, &msg).await?; chat_llm_tools::reject(db, tc_id, &msg).await?;
} }
return Ok(Json(ResolveToolResponse { return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id, tool_call_id: tc_id,
@@ -169,7 +181,7 @@ pub async fn resolve_tool(
// `restart` calls process::exit — mark done in DB first. // `restart` calls process::exit — mark done in DB first.
if tc_name == tn::RESTART { if tc_name == tn::RESTART {
chat_llm_tools::complete(skald.db(), tc_id, "Riavvio avviato.", "string").await?; chat_llm_tools::complete(db, tc_id, "Riavvio avviato.", "string").await?;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in // Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134 // whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor). // instead of 255 — breaking the run.sh restart supervisor).
@@ -177,7 +189,7 @@ pub async fn resolve_tool(
} }
// ── Live path: LLM loop is blocked waiting for approval ────────────────── // ── Live path: LLM loop is blocked waiting for approval ──────────────────
if skald.approval() if ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Approved) .resolve_for_tool_call(tc_id, ApprovalDecision::Approved)
.await .await
{ {
@@ -196,9 +208,9 @@ pub async fn resolve_tool(
// via `execute_tool_call` (gate skipped) and continues the loop. Events stream // via `execute_tool_call` (gate skipped) and continues the loop. Events stream
// to the reconnected client through the global bus; return immediately. // to the reconnected client through the global bus; return immediately.
if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" { if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" {
let handler = skald.chat_hub().handler_for_session(session_id).await?; let handler = ctx.chat_hub.handler_for_session(session_id).await?;
handler.mark_pre_approved(tc_id); handler.mark_pre_approved(tc_id);
let hub = skald.chat_hub().clone(); let hub = ctx.chat_hub.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await { if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed"); tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed");
@@ -213,12 +225,12 @@ pub async fn resolve_tool(
} }
// Simple tools: execute directly on the owning session and return the result. // Simple tools: execute directly on the owning session and return the result.
let handler = skald.chat_hub().handler_for_session(session_id).await?; let handler = ctx.chat_hub.handler_for_session(session_id).await?;
match handler.execute_tool(&tc_name, args).await { match handler.execute_tool(&tc_name, args).await {
Ok(result) => { Ok(result) => {
let wire = result.to_wire(); let wire = result.to_wire();
let kind = result.kind(); let kind = result.kind();
chat_llm_tools::complete(skald.db(), tc_id, &wire, kind).await?; chat_llm_tools::complete(db, tc_id, &wire, kind).await?;
Ok(Json(ResolveToolResponse { Ok(Json(ResolveToolResponse {
tool_call_id: tc_id, tool_call_id: tc_id,
status: "done".to_string(), status: "done".to_string(),
@@ -228,7 +240,7 @@ pub async fn resolve_tool(
} }
Err(e) => { Err(e) => {
let msg = e.to_string(); let msg = e.to_string();
chat_llm_tools::fail(skald.db(), tc_id, &msg).await?; chat_llm_tools::fail(db, tc_id, &msg).await?;
Err(anyhow::anyhow!(msg).into()) Err(anyhow::anyhow!(msg).into())
} }
} }
@@ -250,8 +262,11 @@ fn default_per_page() -> i64 { 20 }
pub async fn list_sessions( pub async fn list_sessions(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<ListSessionsQuery>, Query(q): Query<ListSessionsQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let per_page = q.per_page.max(1).min(100); let per_page = q.per_page.max(1).min(100);
let offset = ((q.page.max(1)) - 1) * per_page; let offset = ((q.page.max(1)) - 1) * per_page;
let src = q.source.as_deref(); let src = q.source.as_deref();
@@ -261,7 +276,7 @@ pub async fn list_sessions(
WHERE (? IS NULL OR cs.source = ?)", WHERE (? IS NULL OR cs.source = ?)",
) )
.bind(src).bind(src) .bind(src).bind(src)
.fetch_one(&**skald.db()).await?; .fetch_one(&**db).await?;
let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>( let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>(
"SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive, "SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive,
@@ -278,7 +293,7 @@ pub async fn list_sessions(
) )
.bind(src).bind(src) .bind(src).bind(src)
.bind(per_page).bind(offset) .bind(per_page).bind(offset)
.fetch_all(&**skald.db()).await?; .fetch_all(&**db).await?;
let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| { let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| {
json!({ json!({
@@ -308,9 +323,12 @@ pub struct SessionIdPath { pub id: i64 }
pub async fn get_session_detail( pub async fn get_session_detail(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SessionIdPath>, Path(p): Path<SessionIdPath>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let session = chat_sessions::find_by_id(skald.db(), p.id) let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let session = chat_sessions::find_by_id(db, p.id)
.await? .await?
.ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?;
@@ -318,10 +336,10 @@ pub async fn get_session_detail(
"SELECT created_at FROM chat_sessions WHERE id = ?", "SELECT created_at FROM chat_sessions WHERE id = ?",
) )
.bind(p.id) .bind(p.id)
.fetch_optional(&**skald.db()) .fetch_optional(&**db)
.await?; .await?;
let all_stacks = chat_sessions_stack::all_for_session(skald.db(), session.id).await?; let all_stacks = chat_sessions_stack::all_for_session(db, session.id).await?;
let subagent_map: HashMap<i64, SessionStack> = all_stacks let subagent_map: HashMap<i64, SessionStack> = all_stacks
.iter() .iter()
@@ -340,7 +358,7 @@ pub async fn get_session_detail(
}; };
let mut messages: Vec<Value> = Vec::new(); let mut messages: Vec<Value> = Vec::new();
build_debug_items(skald.db(), skald.tools(), &main_stack, &subagent_map, &mut messages).await?; build_debug_items(db, skald.tools(), &main_stack, &subagent_map, &mut messages).await?;
Ok(Json(json!({ Ok(Json(json!({
"session": { "session": {
+5 -3
View File
@@ -2,7 +2,7 @@ use std::path::{Path as StdPath, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Multipart, Path, State}, extract::{Multipart, Path, State},
}; };
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -11,7 +11,7 @@ use core_api::message_meta::Attachment;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use skald_core::tools::fs as fs_tools; use skald_core::tools::fs as fs_tools;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
use super::sessions::SourcePath; use super::sessions::SourcePath;
/// `POST /api/{source}/uploads` /// `POST /api/{source}/uploads`
@@ -25,12 +25,14 @@ use super::sessions::SourcePath;
/// size) so the client can show chips and echo them back when sending the message. /// size) so the client can show chips and echo them back when sending the message.
pub async fn upload( pub async fn upload(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SourcePath>, Path(p): Path<SourcePath>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<Vec<Attachment>>, ApiError> { ) -> Result<Json<Vec<Attachment>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve (creating if needed) the source's session so uploads land in the // Resolve (creating if needed) the source's session so uploads land in the
// directory the message will reference. // directory the message will reference.
let session_id = skald.chat_hub().session_handler(&p.source).await?.session_id; let session_id = ctx.chat_hub.session_handler(&p.source).await?.session_id;
let dir_rel = format!("data/uploads/{session_id}"); let dir_rel = format!("data/uploads/{session_id}");
let dir_abs = fs_tools::resolve(&dir_rel)?; let dir_abs = fs_tools::resolve(&dir_rel)?;
+39 -19
View File
@@ -6,17 +6,20 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade}, ws::{Message, WebSocket, WebSocketUpgrade},
}, },
response::IntoResponse, response::IntoResponse,
Extension,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use skald_core::chat_hub::{ModelCommandOutcome, SendMessageOptions}; use skald_core::chat_hub::{ChatHub, ModelCommandOutcome, SendMessageOptions};
use skald_core::events::{ClientMessage, ServerEvent}; use skald_core::events::{ClientMessage, ServerEvent};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use core_api::command::CommandApi; use core_api::command::CommandApi;
use super::guard::AuthUser;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct WsParams { pub struct WsParams {
source: Option<String>, source: Option<String>,
@@ -68,16 +71,33 @@ fn dynamic_help(skald: &Skald) -> String {
pub async fn handler( pub async fn handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
Query(params): Query<WsParams>, Query(params): Query<WsParams>,
Extension(auth): Extension<AuthUser>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let source = params.source.unwrap_or_else(|| "web".to_string()); let source = params.source.unwrap_or_else(|| "web".to_string());
ws.on_upgrade(move |socket| handle_socket(socket, skald, source)) ws.on_upgrade(move |socket| handle_socket(socket, skald, source, auth.user_id))
} }
// ── Socket loop ─────────────────────────────────────────────────────────────── // ── Socket loop ───────────────────────────────────────────────────────────────
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String) { async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String, user_id: String) {
let session_handler = match skald.chat_hub().session_handler(&source).await { // Resolve the caller's per-user runtime. The pool is unlocked at login, so an
// authenticated connection normally has a context; a missing one means the
// database re-locked (e.g. a restart with no re-login) — report and close.
let ctx = match skald.user_context(&user_id).await {
Some(c) => c,
None => {
let _ = socket.send(to_msg(&ServerEvent::Error {
message: "session expired — please log in again".to_string(),
})).await;
return;
}
};
// Every chat operation for this connection goes through the user's own hub, so
// sessions land in their `{userid}.db` and events never cross to another user.
let chat_hub: Arc<ChatHub> = Arc::clone(&ctx.chat_hub);
let session_handler = match chat_hub.session_handler(&source).await {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await; let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
@@ -85,9 +105,9 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
}; };
info!(source, "WebSocket connected"); info!(source, user = %user_id, "WebSocket connected");
let mut rx = skald.chat_hub().events(&source); let mut rx = chat_hub.events(&source);
// Tell this (possibly reloaded) client whether a turn is already running for // Tell this (possibly reloaded) client whether a turn is already running for
// its session, so it can restore the STOP button. Sent after subscribing to // its session, so it can restore the STOP button. Sent after subscribing to
@@ -109,7 +129,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
// ── resume ──────────────────────────────────────────────────── // ── resume ────────────────────────────────────────────────────
if is_resume_msg(&text) { if is_resume_msg(&text) {
info!("web WS: resume requested"); info!("web WS: resume requested");
let hub = Arc::clone(skald.chat_hub()); let hub = Arc::clone(&chat_hub);
let src = source.clone(); let src = source.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = hub.resume(&src).await { if let Err(e) = hub.resume(&src).await {
@@ -127,10 +147,10 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
session_handler.cancel_pending_questions().await; session_handler.cancel_pending_questions().await;
continue; continue;
} }
if handle_approval_msg(&text, skald.chat_hub()).await { continue; } if handle_approval_msg(&text, &chat_hub).await { continue; }
if handle_question_answer_msg(&text, &session_handler).await { continue; } if handle_question_answer_msg(&text, &session_handler).await { continue; }
if handle_data_msg(&text, &skald) { continue; } if handle_data_msg(&text, &skald) { continue; }
if handle_select_client_msg(&text, &source, skald.chat_hub()).await { continue; } if handle_select_client_msg(&text, &source, &chat_hub).await { continue; }
// ── /sethome ────────────────────────────────────────────────── // ── /sethome ──────────────────────────────────────────────────
let client_msg: ClientMessage = match serde_json::from_str(&text) { let client_msg: ClientMessage = match serde_json::from_str(&text) {
@@ -146,7 +166,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
let cmd = client_msg.content.trim(); let cmd = client_msg.content.trim();
if cmd == "/sethome" { if cmd == "/sethome" {
let msg = match skald.chat_hub().set_home(&source).await { let msg = match chat_hub.set_home(&source).await {
Ok(_) => "🏠 Web impostato come **home**. Le notifiche degli agenti arriveranno qui.".to_string(), Ok(_) => "🏠 Web impostato come **home**. Le notifiche degli agenti arriveranno qui.".to_string(),
Err(e) => format!("⚠️ Errore: {e}"), Err(e) => format!("⚠️ Errore: {e}"),
}; };
@@ -172,7 +192,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if cmd == "/context" { if cmd == "/context" {
match skald.chat_hub().context_info(&source).await { match chat_hub.context_info(&source).await {
Ok((input, output)) => { Ok((input, output)) => {
let input_str = input.map_or("?".to_string(), |t| t.to_string()); let input_str = input.map_or("?".to_string(), |t| t.to_string());
let output_str = output.map_or("?".to_string(), |t| t.to_string()); let output_str = output.map_or("?".to_string(), |t| t.to_string());
@@ -192,7 +212,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if cmd == "/cost" { if cmd == "/cost" {
match skald.chat_hub().cost_info(&source).await { match chat_hub.cost_info(&source).await {
Ok(Some(c)) => { Ok(Some(c)) => {
let _ = socket.send(to_msg(&ServerEvent::Done { let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0, message_id: 0,
@@ -219,7 +239,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if cmd == "/compact" { if cmd == "/compact" {
match skald.chat_hub().force_compact(&source).await { match chat_hub.force_compact(&source).await {
Ok(true) => { Ok(true) => {
let _ = socket.send(to_msg(&ServerEvent::Done { let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0, message_id: 0,
@@ -246,7 +266,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if cmd == "/resettools" { if cmd == "/resettools" {
match skald.chat_hub().reset_mcp(&source).await { match chat_hub.reset_mcp(&source).await {
Ok(()) => { Ok(()) => {
let _ = socket.send(to_msg(&ServerEvent::Done { let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0, message_id: 0,
@@ -264,7 +284,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if cmd == "/models" { if cmd == "/models" {
let items = skald.chat_hub().list_clients_marked(&source).await; let items = chat_hub.list_clients_marked(&source).await;
let content = format_models_md(&items); let content = format_models_md(&items);
let _ = socket.send(to_msg(&ServerEvent::Done { let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0, message_id: 0,
@@ -277,7 +297,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
} }
if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) { if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) {
let outcome = skald.chat_hub().apply_model_command(&source, arg).await; let outcome = chat_hub.apply_model_command(&source, arg).await;
let content = match outcome { let content = match outcome {
ModelCommandOutcome::Set(name) => format!("✅ Model set: **{name}**"), ModelCommandOutcome::Set(name) => format!("✅ Model set: **{name}**"),
ModelCommandOutcome::Cleared => "✅ Model reset to **auto**.".to_string(), ModelCommandOutcome::Cleared => "✅ Model reset to **auto**.".to_string(),
@@ -353,14 +373,14 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
// client lives in ChatHub.selected_clients[source]. The web // client lives in ChatHub.selected_clients[source]. The web
// `/model` command and the dropdown both flow through // `/model` command and the dropdown both flow through
// set_selected_client, which broadcasts ClientSelected. // set_selected_client, which broadcasts ClientSelected.
client_name: skald.chat_hub().get_selected_client(&source).await, client_name: chat_hub.get_selected_client(&source).await,
extra_system_context: Some(WEB_FORMAT_CONTEXT.to_string()), extra_system_context: Some(WEB_FORMAT_CONTEXT.to_string()),
// SPA-only tool: lets the assistant open a file in the user's // SPA-only tool: lets the assistant open a file in the user's
// viewer. Injected here (not in the registry) so it exists only // viewer. Injected here (not in the registry) so it exists only
// for ws.rs clients (web + mobile), never for the Telegram plugin. // for ws.rs clients (web + mobile), never for the Telegram plugin.
interface_tools: vec![ interface_tools: vec![
skald_core::tools::show_file::make_tool( skald_core::tools::show_file::make_tool(
Arc::clone(skald.chat_hub()), Arc::clone(&chat_hub),
source.clone(), source.clone(),
), ),
], ],
@@ -368,7 +388,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String)
}; };
// send_message only enqueues — the turn runs on ChatHub's per-source // send_message only enqueues — the turn runs on ChatHub's per-source
// consumer — so awaiting inline keeps this WS read loop responsive. // consumer — so awaiting inline keeps this WS read loop responsive.
if let Err(e) = skald.chat_hub().send_message(&source, &content, opts).await { if let Err(e) = chat_hub.send_message(&source, &content, opts).await {
tracing::error!(error = %e, source = %source, "send_message enqueue failed"); tracing::error!(error = %e, source = %source, "send_message enqueue failed");
} }
} }
+14 -4
View File
@@ -6,24 +6,34 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade}, ws::{Message, WebSocket, WebSocketUpgrade},
}, },
response::IntoResponse, response::IntoResponse,
Extension,
}; };
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{info, warn}; use tracing::{info, warn};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::guard::AuthUser;
pub async fn handler( pub async fn handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
Path(id): Path<i64>, Path(id): Path<i64>,
Extension(auth): Extension<AuthUser>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, skald, id)) ws.on_upgrade(move |socket| handle_socket(socket, skald, id, auth.user_id))
} }
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64) { async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, session_id: i64, user_id: String) {
info!(session_id, "session-watch WS connected"); info!(session_id, user = %user_id, "session-watch WS connected");
let mut rx = skald.chat_hub().events("session-watch"); // Watch this user's own event stream, so a session-watch only ever sees events
// for sessions in the watcher's `{userid}.db`.
let ctx = match skald.user_context(&user_id).await {
Some(c) => c,
None => return,
};
let mut rx = ctx.chat_hub.events("session-watch");
loop { loop {
tokio::select! { tokio::select! {
+4 -3
View File
@@ -164,15 +164,16 @@ pub async fn run_backend() -> Result<Backend> {
/// Build the plugin list. Extracted so both entry points share the same set. /// Build the plugin list. Extracted so both entry points share the same set.
fn build_plugins() -> Vec<Arc<dyn Plugin>> { fn build_plugins() -> Vec<Arc<dyn Plugin>> {
// NOTE (multi-user slice): telegram-bot, mobile-connector and honcho are
// single-user / global-ChatEventBus-subscriber plugins. They assume one user
// and cannot work under per-user isolation yet, so they are dropped from the
// build until they become multi-user-aware. See blueprint §17 and the plan.
let mut plugins: Vec<Arc<dyn Plugin>> = vec![ let mut plugins: Vec<Arc<dyn Plugin>> = vec![
Arc::new(plugin_honcho::HonchoPlugin::new()),
Arc::new(plugin_telegram_bot::TelegramPlugin::new("secrets")),
Arc::new(plugin_tailscale_remote::RemotePlugin::new()), Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
Arc::new(plugin_comfyui::ComfyUIPlugin::new()), Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()), Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()), Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),
Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()), Arc::new(plugin_elevenlabs::ElevenLabsPlugin::new()),
Arc::new(plugin_mobile_connector::MobileConnectorPlugin::new()),
]; ];
#[cfg(feature = "whisper-local")] #[cfg(feature = "whisper-local")]
plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new())); plugins.push(Arc::new(plugin_transcribe_whisper_local::WhisperLocalPlugin::new()));