v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan
The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.
What landed:
- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
clawdforge::Client; generate() / cleanup() / audit() each build a
RunRequest with the right system prompt + model alias (always opus),
call client.run(), return a PassOutput.
Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
chain. Whisper-large-v3 STT does text-to-text verification on every
render; an optional Gemini Flash audio pass catches subjective issues
(prosody, tone) Whisper can't see. Reroll loop on crit findings.
What's still stubbed:
- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.
Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
This commit is contained in:
parent
4a91e0738d
commit
f71b533e52
17 changed files with 3340 additions and 19 deletions
528
Cargo.lock
generated
528
Cargo.lock
generated
|
|
@ -82,6 +82,16 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
|
|
@ -219,6 +229,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -273,6 +289,23 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clawdforge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
|
|
@ -349,6 +382,24 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -436,6 +487,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -453,6 +510,12 @@ dependencies = [
|
|||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
|
|
@ -468,6 +531,21 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
|
|
@ -512,6 +590,17 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
|
|
@ -530,8 +619,10 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
|
|
@ -556,8 +647,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -568,11 +675,30 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
@ -605,6 +731,12 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
|
|
@ -693,6 +825,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
|
|
@ -701,6 +834,23 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -709,13 +859,21 @@ version = "0.1.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -863,6 +1021,12 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -936,6 +1100,12 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
|
|
@ -957,6 +1127,12 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1016,6 +1192,16 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1047,7 +1233,7 @@ dependencies = [
|
|||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1082,6 +1268,16 @@ dependencies = [
|
|||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
|
@ -1226,6 +1422,61 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[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.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
|
|
@ -1235,6 +1486,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
|
|
@ -1248,8 +1505,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1259,7 +1526,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1271,6 +1548,15 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -1318,6 +1604,48 @@ version = "0.8.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"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",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
|
|
@ -1345,13 +1673,32 @@ dependencies = [
|
|||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
|
|
@ -1372,6 +1719,7 @@ version = "1.14.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -1530,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1559,11 +1907,12 @@ version = "0.0.1"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clawdforge",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
|
|
@ -1655,7 +2004,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
|
|
@ -1732,7 +2081,7 @@ dependencies = [
|
|||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
|
|
@ -1740,7 +2089,7 @@ dependencies = [
|
|||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
|
|
@ -1772,14 +2121,14 @@ dependencies = [
|
|||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
|
|
@ -1805,7 +2154,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
|
|
@ -1856,6 +2205,9 @@ name = "sync_wrapper"
|
|||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
|
|
@ -1868,13 +2220,46 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1950,6 +2335,16 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
|
|
@ -1961,6 +2356,19 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
|
|
@ -1985,13 +2393,16 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
|||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2082,12 +2493,24 @@ dependencies = [
|
|||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
|
@ -2181,6 +2604,15 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
@ -2224,6 +2656,16 @@ dependencies = [
|
|||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
|
|
@ -2278,6 +2720,19 @@ dependencies = [
|
|||
"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]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
|
@ -2290,6 +2745,26 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"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]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
@ -2525,6 +3000,29 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"base64",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -20,13 +20,24 @@ What's wired:
|
|||
- `skald serve` exposes `/health` and runs migrations on boot
|
||||
- Single-container deploy: postgres + skald in one image
|
||||
|
||||
Wired (this commit):
|
||||
|
||||
- clawdforge Rust SDK vendored at `vendor/clawdforge/` (upstream:
|
||||
`Sulkta-Coop/clawdforge` `clients/rust/`)
|
||||
- `skald-core::forge` — three-pass orchestration shell (gen / cleanup /
|
||||
audit). Prompts are TODO stubs; pipeline plumbing is in place.
|
||||
|
||||
Not yet wired:
|
||||
|
||||
- Web UI (the inbox + browse + queue surface)
|
||||
- clawdforge calls (the actual generate / cleanup / canon-audit
|
||||
pipeline)
|
||||
- Embeddings + similarity search
|
||||
- TTS sidecar
|
||||
- Prompt templates for the three passes (heavy prompt-engineering
|
||||
work — own session)
|
||||
- `skald-core::context` — assemble the LLM context blob from DB rows
|
||||
(bible + characters + parent prose summaries + similarity-matched
|
||||
passages)
|
||||
- Embeddings backfill + ivfflat index
|
||||
- TTS sidecar container + post-render audit chain (see
|
||||
`docs/tts-pipeline.md`)
|
||||
|
||||
## v0.1 smoke
|
||||
|
||||
|
|
|
|||
146
docs/tts-pipeline.md
Normal file
146
docs/tts-pipeline.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# TTS pipeline (v0.2 plan)
|
||||
|
||||
Generation lands in v0.2; narration follows. This doc captures the
|
||||
shape so the v0.1 schema decisions don't drift away from the v0.2
|
||||
implementation.
|
||||
|
||||
## Render path
|
||||
|
||||
```
|
||||
chapter row (chapters.body_md)
|
||||
│
|
||||
▼
|
||||
1. pronunciation pre-pass
|
||||
│ pull pronunciation_overrides for (story_id, *) UNION global
|
||||
│ substitute proper nouns with phoneme markers F5 understands
|
||||
▼
|
||||
2. F5-TTS render
|
||||
│ reference: voices.reference_path + voices.reference_text
|
||||
│ output: .wav, 24 kHz, mono
|
||||
▼
|
||||
3. narration_runs row
|
||||
│ chapter_id, voice_id, engine="f5-tts", engine_version, seed,
|
||||
│ output_path, duration_seconds, status="succeeded"
|
||||
▼
|
||||
4. POST-RENDER AUDIT CHAIN ◄── this is the cwho-shape audit we use
|
||||
to catch "spoken word that would
|
||||
wake you up at 2am"
|
||||
```
|
||||
|
||||
## Audit chain
|
||||
|
||||
### Tier 1 — Whisper-large-v3 STT + word-diff (text-to-text)
|
||||
|
||||
```
|
||||
chapter.wav
|
||||
│
|
||||
▼
|
||||
ffmpeg -i chapter.wav -af "silenceremove=..."
|
||||
-f segment -segment_time 30 -reset_timestamps 1 chunk%04d.wav
|
||||
│
|
||||
▼
|
||||
for each chunk:
|
||||
whisper-large-v3 → transcript
|
||||
word-level diff vs (source_text + pronunciation_overrides applied)
|
||||
│
|
||||
▼
|
||||
substitutions / drops / inserts
|
||||
│
|
||||
▼
|
||||
narration_findings rows:
|
||||
kind = pronunciation | skip | insert
|
||||
timestamp_start/end (chunk-relative + chunk offset)
|
||||
expected_text, heard_text
|
||||
severity:
|
||||
pronunciation: warn (one wrong word — annoyance)
|
||||
skip: crit (line silently dropped — listener loses thread)
|
||||
insert: warn (extra word, usually a glitch)
|
||||
detector = "whisper-large-v3"
|
||||
```
|
||||
|
||||
Whisper runs locally on Lucy's 8GB GPU (~3GB VRAM). Free, fast.
|
||||
Captures mispronunciations that come out as a different real word.
|
||||
|
||||
### Tier 2 — audio-native LLM review (subjective)
|
||||
|
||||
Optional, more expensive, covers what Whisper can't:
|
||||
|
||||
```
|
||||
chapter.wav + source_text
|
||||
│
|
||||
▼
|
||||
clawdforge.run({
|
||||
model: "gemini-flash-audio" | "gpt-4o-audio",
|
||||
files: [chapter.wav],
|
||||
prompt: "...listen to this rendered audiobook chapter...flag
|
||||
spans where (a) inflection breaks meaning, (b) pacing
|
||||
makes the listener lose the thread, (c) any audio
|
||||
glitch or dropout, (d) emotional tone is wrong..."
|
||||
})
|
||||
│
|
||||
▼
|
||||
narration_findings rows:
|
||||
kind = prosody | tone | glitch
|
||||
severity per the model's call
|
||||
detector = "gemini-flash-audio" (etc)
|
||||
```
|
||||
|
||||
Claude (the model behind clawdforge's default) doesn't have audio
|
||||
modality (mid-2026). Audio-native models route through clawdforge
|
||||
to Gemini Flash / GPT-4o-audio when needed. Cost: ~$0.05/chapter
|
||||
for Gemini Flash.
|
||||
|
||||
### Reroll loop
|
||||
|
||||
```
|
||||
SELECT count(*) FROM narration_findings
|
||||
WHERE run_id = $1 AND severity = 'crit' AND NOT resolved
|
||||
│
|
||||
▼ if > 0:
|
||||
update narration_runs.status = 'rerolled'
|
||||
queue a new render with seed = $old_seed + 1
|
||||
│
|
||||
▼
|
||||
narration_findings rows referencing the old run_id stay
|
||||
(audit trail) — the new run gets its own findings set
|
||||
```
|
||||
|
||||
After two reroll attempts with crit findings still present, the
|
||||
chapter is marked for operator review (out-of-band — e.g.,
|
||||
re-edit the source text, add more pronunciation overrides, hand-
|
||||
patch the audio).
|
||||
|
||||
## Why Whisper for text-to-text verification
|
||||
|
||||
Whisper's word-error-rate on clean audiobook audio is ~3-5%. That
|
||||
means *most* of what it transcribes back will match the source
|
||||
exactly. The *deltas* are precisely the words the TTS got wrong.
|
||||
|
||||
False-positive rate for true Whisper transcription errors (Whisper
|
||||
heard it right, called something different) is small enough to
|
||||
treat as noise — those findings get autoresolved if they don't
|
||||
reproduce on a reroll.
|
||||
|
||||
Whisper's proper-noun WER is HIGHER than overall WER — which is
|
||||
actually what we want here. The harder Whisper finds it to
|
||||
transcribe a name back from F5's output, the more likely F5 got
|
||||
the name wrong in the first place.
|
||||
|
||||
## What this doc is NOT
|
||||
|
||||
- Not a prompt template for the audio-LLM call (TBD in v0.2)
|
||||
- Not a Whisper config file (TBD: which Whisper, language, beam size, VAD)
|
||||
- Not the TTS sidecar container shape (separate spec)
|
||||
|
||||
## When this gets wired
|
||||
|
||||
After the generation pipeline (gen → cleanup → canon-audit) is
|
||||
working end-to-end. TTS is downstream of "we have a story to
|
||||
render."
|
||||
|
||||
Order of v0.2 work:
|
||||
|
||||
1. forge.rs prompt templates (gen/cleanup/audit) — fill in stubs
|
||||
2. context.rs — assemble blob from DB for a given story_id
|
||||
3. web UI for queueing + status
|
||||
4. TTS sidecar container — F5 + ffmpeg + Whisper + this audit chain
|
||||
|
|
@ -18,6 +18,7 @@ tracing = { workspace = true }
|
|||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
clawdforge = { path = "../vendor/clawdforge" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
|
|
|||
33
skald-core/src/config.rs
Normal file
33
skald-core/src/config.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! Configuration for skald-core consumers.
|
||||
//!
|
||||
//! Configs are passed in explicitly by the calling binary, not loaded
|
||||
//! from disk here — the lib stays env-agnostic. (skald-the-binary
|
||||
//! reads env vars + maps them into these structs.)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForgeConfig {
|
||||
/// Base URL of the clawdforge HTTP service. Defaults to
|
||||
/// `http://clawdforge.sulkta.lan:8800` in production; override
|
||||
/// for tests via env.
|
||||
pub base_url: String,
|
||||
|
||||
/// App-level bearer token. Resolved by the binary from
|
||||
/// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed.
|
||||
pub app_token: String,
|
||||
|
||||
/// Model alias passed to clawdforge → `claude -p --model`. Skald
|
||||
/// is opinionated: always opus max effort. Default reflects that.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl Default for ForgeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "http://clawdforge.sulkta.lan:8800".into(),
|
||||
app_token: String::new(),
|
||||
model: "opus".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
203
skald-core/src/forge.rs
Normal file
203
skald-core/src/forge.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//! clawdforge wiring. Three passes per chapter; the actual prompt
|
||||
//! templates are TODO (v0.2 prompt-engineering sprint) — this module
|
||||
//! ships the plumbing so prompts can be filled in without
|
||||
//! refactoring.
|
||||
//!
|
||||
//! The three passes:
|
||||
//!
|
||||
//! 1. **gen** — produces a new chapter draft from an assembled
|
||||
//! context blob (parent prose + bible + characters + similarity-
|
||||
//! matched passages, all from the database). Opus, max effort.
|
||||
//!
|
||||
//! 2. **cleanup** — polishes the draft for prose quality, voice
|
||||
//! consistency, dialogue rhythm, pacing dead spots. Same Opus,
|
||||
//! fresh eyes; sees gen pass output + same context.
|
||||
//!
|
||||
//! 3. **audit** — third Opus reads parent prose + sequel prose +
|
||||
//! bible, returns structured findings: dropped threads, character
|
||||
//! voice drift, retconned facts, timeline contradictions. Output
|
||||
//! parses into rows for the `audit_findings` table.
|
||||
//!
|
||||
//! Every pass is logged as a `generation_runs` row before / after
|
||||
//! for cost tracking, replay, and forensics.
|
||||
//!
|
||||
//! ## Naming context
|
||||
//!
|
||||
//! The Rust binding for clawdforge is the upstream `clawdforge` crate
|
||||
//! (vendored at `vendor/clawdforge`). This module is the skald-side
|
||||
//! glue: turn a story-id + a pass-kind into the right RunRequest +
|
||||
//! parse the response into the right shape.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use clawdforge::{Client, ClientBuilder, RunRequest, RunResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ForgeConfig;
|
||||
|
||||
/// Thin wrapper around the clawdforge `Client`. Configured once,
|
||||
/// cheap to clone — each pass just calls `.run()` with a different
|
||||
/// prompt.
|
||||
#[derive(Clone)]
|
||||
pub struct Forge {
|
||||
client: Client,
|
||||
/// The model alias we pass to clawdforge. Skald is opinionated:
|
||||
/// always opus max effort. (See `project_story_writer_container.md`.)
|
||||
/// `clawdforge` resolves the alias to the actual claude CLI flag.
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// Per-pass output. `result` is the raw response from clawdforge.
|
||||
/// Callers parse it into the shape they need.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PassOutput {
|
||||
pub kind: PassKind,
|
||||
pub result: RunResult,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// What a given pass over the model is for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PassKind {
|
||||
/// First-pass long-form draft from prompt + context.
|
||||
Gen,
|
||||
/// Polish + humanize the gen pass output.
|
||||
Cleanup,
|
||||
/// Canon audit across parent + sequel. Outputs findings JSON.
|
||||
Audit,
|
||||
}
|
||||
|
||||
impl PassKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gen => "gen",
|
||||
Self::Cleanup => "cleanup",
|
||||
Self::Audit => "audit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Forge {
|
||||
pub fn new(cfg: &ForgeConfig) -> anyhow::Result<Self> {
|
||||
let client = ClientBuilder::default()
|
||||
.base_url(&cfg.base_url)
|
||||
.token(&cfg.app_token)
|
||||
// Generation passes are slow — 600s is the clawdforge
|
||||
// server-side max anyway, and gen passes routinely hit
|
||||
// 5+ minutes on opus max-effort. Default 120s would
|
||||
// strand them.
|
||||
.timeout(Duration::from_secs(600))
|
||||
.user_agent(concat!("skald/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
model: cfg.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// First-pass draft. `prompt` is the user-supplied story prompt;
|
||||
/// `context` is the full assembled blob (bible + characters +
|
||||
/// parent prose summaries + passages).
|
||||
///
|
||||
/// Prompt template is TODO (v0.2). Stub builds the simplest
|
||||
/// possible request shape so the wiring compiles.
|
||||
pub async fn generate(&self, prompt: &str, context: &str) -> anyhow::Result<PassOutput> {
|
||||
let body = build_request(
|
||||
&self.model,
|
||||
PassKind::Gen,
|
||||
prompt,
|
||||
context,
|
||||
SYSTEM_GEN_TODO,
|
||||
);
|
||||
let r = self.client.run(body).await?;
|
||||
let duration_ms = r.duration_ms;
|
||||
Ok(PassOutput { kind: PassKind::Gen, result: r, duration_ms })
|
||||
}
|
||||
|
||||
/// Cleanup / humanize pass over the gen draft.
|
||||
pub async fn cleanup(&self, draft: &str, context: &str) -> anyhow::Result<PassOutput> {
|
||||
let body = build_request(
|
||||
&self.model,
|
||||
PassKind::Cleanup,
|
||||
draft,
|
||||
context,
|
||||
SYSTEM_CLEANUP_TODO,
|
||||
);
|
||||
let r = self.client.run(body).await?;
|
||||
let duration_ms = r.duration_ms;
|
||||
Ok(PassOutput { kind: PassKind::Cleanup, result: r, duration_ms })
|
||||
}
|
||||
|
||||
/// Canon audit comparing parent + sequel against the bible.
|
||||
/// Expected to return structured JSON parseable into
|
||||
/// `Vec<AuditFinding>`.
|
||||
pub async fn audit(&self, parent_prose: &str, sequel_prose: &str, bible: &str) -> anyhow::Result<PassOutput> {
|
||||
let body = build_audit_request(
|
||||
&self.model,
|
||||
parent_prose,
|
||||
sequel_prose,
|
||||
bible,
|
||||
);
|
||||
let r = self.client.run(body).await?;
|
||||
let duration_ms = r.duration_ms;
|
||||
Ok(PassOutput { kind: PassKind::Audit, result: r, duration_ms })
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(model: &str, kind: PassKind, primary: &str, context: &str, system: &str) -> RunRequest {
|
||||
let prompt = format!(
|
||||
"# Pass: {kind}\n\n## Context\n\n{context}\n\n## Input\n\n{primary}",
|
||||
kind = kind.as_str(),
|
||||
);
|
||||
RunRequest {
|
||||
prompt,
|
||||
model: Some(model.to_string()),
|
||||
system: Some(system.to_string()),
|
||||
timeout_secs: Some(600),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_audit_request(model: &str, parent: &str, sequel: &str, bible: &str) -> RunRequest {
|
||||
let prompt = format!(
|
||||
"## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON: {{ \"findings\": [ {{ \"severity\": \"info|warn|crit\", \"area\": \"character|continuity|tone|fact|timeline|other\", \"body\": \"...\" }} ] }}"
|
||||
);
|
||||
RunRequest {
|
||||
prompt,
|
||||
model: Some(model.to_string()),
|
||||
system: Some(SYSTEM_AUDIT_TODO.to_string()),
|
||||
timeout_secs: Some(600),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prompt templates (TODO v0.2 — these are placeholder stubs) ───
|
||||
|
||||
const SYSTEM_GEN_TODO: &str = "You are a long-form fiction author. \
|
||||
Write in measured, literary prose. Honor the bible and character voices \
|
||||
exactly. (Full prompt template: TODO v0.2.)";
|
||||
|
||||
const SYSTEM_CLEANUP_TODO: &str = "You are a copy editor for long-form fiction. \
|
||||
Polish the draft for prose quality, tighten dialogue, fix pacing dead \
|
||||
spots, keep voice consistent. Do not add new plot. (Full prompt template: TODO v0.2.)";
|
||||
|
||||
const SYSTEM_AUDIT_TODO: &str = "You are a canon auditor. Compare the parent \
|
||||
and sequel against the bible. Flag contradictions, character voice drift, \
|
||||
retconned facts, dropped threads, timeline issues. Output structured \
|
||||
JSON only — no commentary. (Full prompt template: TODO v0.2.)";
|
||||
|
||||
/// Audit finding shape returned by the audit pass. Parses out of the
|
||||
/// `result` field on the audit pass's [`RunResult`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditFinding {
|
||||
pub severity: String,
|
||||
pub area: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Wrapper shape for the audit response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditResponse {
|
||||
pub findings: Vec<AuditFinding>,
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
//! assembly for LLM calls. The story-independence rule: nothing in
|
||||
//! this crate knows about any specific story. Every story is rows.
|
||||
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod forge;
|
||||
pub mod ingest;
|
||||
pub mod models;
|
||||
|
||||
|
|
|
|||
30
vendor/clawdforge/Cargo.toml
vendored
Normal file
30
vendor/clawdforge/Cargo.toml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "clawdforge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Async Rust client for the clawdforge HTTP service (a LAN bearer-token-gated wrapper around `claude -p`)."
|
||||
repository = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge"
|
||||
readme = "README.md"
|
||||
keywords = ["claude", "http-client", "sdk", "async"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["fs", "io-util", "rt"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
url = "2"
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] }
|
||||
wiremock = "0.6"
|
||||
tempfile = "3"
|
||||
|
||||
[[example]]
|
||||
name = "basic"
|
||||
path = "examples/basic.rs"
|
||||
270
vendor/clawdforge/README.md
vendored
Normal file
270
vendor/clawdforge/README.md
vendored
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# clawdforge — Rust client
|
||||
|
||||
Async Rust SDK for [clawdforge], a small LAN-only HTTP service that wraps
|
||||
`claude -p` subprocess calls behind a bearer-token-gated REST API.
|
||||
|
||||
[clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
|
||||
|
||||
- Tokio + reqwest under the hood
|
||||
- `serde` + `serde_json` types
|
||||
- Streaming multipart upload (`tokio::fs::File`, no full-file buffer)
|
||||
- Builder pattern for configuration
|
||||
- Typed `RunResult::as_json::<T>()` and `as_text()` helpers over a
|
||||
`serde_json::Value` payload
|
||||
|
||||
## Install
|
||||
|
||||
This crate is not on crates.io. Pull it directly from the upstream git host:
|
||||
|
||||
```sh
|
||||
cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev <pin>
|
||||
```
|
||||
|
||||
Or pin manually in `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "<pin>" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
For an in-repo workspace consumer, point at the `clients/rust/` path:
|
||||
|
||||
```toml
|
||||
clawdforge = { path = "../clawdforge/clients/rust" }
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```rust
|
||||
use clawdforge::{Client, RunRequest};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = Client::builder()
|
||||
.base_url("http://localhost:8800")
|
||||
.token("cf_xxxxxxxxxxxxxxxx")
|
||||
.build()?;
|
||||
|
||||
// Liveness — does not require a token, but sends one if configured.
|
||||
let h = client.healthz().await?;
|
||||
println!("claude present: {} version: {:?}", h.claude_present, h.claude_version);
|
||||
|
||||
// Run a prompt. `result` is a serde_json::Value — narrow via .as_json::<T>().
|
||||
let r = client.run(RunRequest {
|
||||
prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
|
||||
model: Some("sonnet".into()),
|
||||
timeout_secs: Some(30),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Hello { hello: String }
|
||||
let typed: Hello = r.as_json()?;
|
||||
println!("{}", typed.hello);
|
||||
|
||||
// Upload a file, then attach it to a follow-up run.
|
||||
let ft = client.upload_file("./recipe.png", Some(3600)).await?;
|
||||
let r2 = client.run(RunRequest {
|
||||
prompt: "extract recipe data".into(),
|
||||
files: Some(vec![ft.file_token]),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
println!("{:?}", r2.result);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-turn / Sessions (v0.2)
|
||||
|
||||
v0.1 `Client::run` is a single-turn shot. v0.2 adds a parallel session API
|
||||
backed by the server's [ACPX]-driven `/sessions/*` surface for back-and-forth
|
||||
agent flows that need context across turns.
|
||||
|
||||
[ACPX]: https://github.com/openclaw/acpx
|
||||
|
||||
```rust
|
||||
use clawdforge::{Client, SessionOptions};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), clawdforge::Error> {
|
||||
let client = Client::builder()
|
||||
.base_url("http://localhost:8800")
|
||||
.token("cf_xxxxxxxxxxxxxxxx")
|
||||
.build()?;
|
||||
|
||||
let mut s = client.new_session(SessionOptions::default()).await?;
|
||||
|
||||
let r1 = s.turn("Read README.md and summarize it").await?;
|
||||
println!("{}", r1.text());
|
||||
|
||||
// Attach files uploaded via Client::upload_file.
|
||||
let r2 = s
|
||||
.turn_with_files(
|
||||
"Now look at the auth flow",
|
||||
&["ff_xyz".into()],
|
||||
)
|
||||
.await?;
|
||||
println!("turn {}: {}", r2.turn_index, r2.text());
|
||||
|
||||
// Explicit close consumes `s` — using it after this is a compile error.
|
||||
s.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
| API | Purpose |
|
||||
|---|---|
|
||||
| `Client::new_session(SessionOptions)` | `POST /sessions` — returns a `Session`. |
|
||||
| `Session::turn(prompt)` | `POST /sessions/{id}/turn` with no files. |
|
||||
| `Session::turn_with_files(prompt, &[token, ...])` | `POST /sessions/{id}/turn` with `ff_*` tokens from `upload_file`. |
|
||||
| `Session::close(self)` | `DELETE /sessions/{id}`. **Consumes `self`** — use-after-close is a compile error. |
|
||||
| `Client::list_sessions()` | `GET /sessions` — sessions visible to the calling token. |
|
||||
| `Client::get_session(id)` | `GET /sessions/{id}` — current state. |
|
||||
|
||||
### Drop fallback
|
||||
|
||||
If a `Session` is dropped without an explicit `close().await?`, `Drop`
|
||||
spawns a best-effort async DELETE via `tokio::spawn` to release the
|
||||
server-side session. This is **best-effort**:
|
||||
|
||||
- The spawned future is not awaited — the calling task continues immediately.
|
||||
- Failures are logged via `tracing::warn!` (target `clawdforge::session`),
|
||||
not panicked.
|
||||
- If `Session` is dropped outside any tokio runtime, the close is skipped
|
||||
with a warning rather than panicking on `tokio::spawn`.
|
||||
- If `close().await?` already ran, `Drop` short-circuits without a second
|
||||
network call (an `AtomicBool` flag tracks closed state).
|
||||
|
||||
For deterministic cleanup, **prefer `s.close().await?`**. The Drop path is a
|
||||
backstop for panics / early returns, not a primary lifecycle hook.
|
||||
|
||||
### `TurnResult::text()`
|
||||
|
||||
Concatenates all `"text"` events into one string. `"thinking"` and
|
||||
`"tool_call"` events are skipped — inspect `result.events` directly if you
|
||||
need them.
|
||||
|
||||
```rust
|
||||
let r = s.turn("hi").await?;
|
||||
let answer: String = r.text();
|
||||
let n_tool_calls = r
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.event_type == "tool_call")
|
||||
.count();
|
||||
```
|
||||
|
||||
### v0.1 compatibility
|
||||
|
||||
The v0.1 surface (`Client::run`, `Client::upload_file`,
|
||||
`Client::create_token`, etc.) is byte-identical. v0.2 is purely additive. v0.1
|
||||
callers do not need to change anything to upgrade.
|
||||
|
||||
## Public API
|
||||
|
||||
### `Client::builder()`
|
||||
|
||||
Builder for the HTTP client.
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `.base_url(url)` | Required. e.g. `"http://localhost:8800"`. |
|
||||
| `.token(t)` | App bearer for `/run`, `/files`. |
|
||||
| `.admin_token(t)` | Admin bearer for `/admin/*`. |
|
||||
| `.timeout(Duration)` | Per-request timeout (default 120 s). |
|
||||
| `.user_agent(s)` | Override `User-Agent` header. |
|
||||
| `.danger_accept_invalid_certs(bool)` | Skip TLS verify (off by default). |
|
||||
| `.build()` | Returns `Result<Client, Error>`. |
|
||||
|
||||
### `Client` async methods
|
||||
|
||||
| Method | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `healthz()` | `GET /healthz` | Returns `Healthz`. |
|
||||
| `run(RunRequest)` | `POST /run` | Returns `RunResult`. 502 surfaces as `Error::Api`. |
|
||||
| `upload_file(path, ttl_secs)` | `POST /files` | Streams from disk; returns `FileToken`. |
|
||||
| `create_token(TokenCreateRequest)` | `POST /admin/tokens` | Admin only. Returns `AppToken`. |
|
||||
| `list_tokens()` | `GET /admin/tokens` | Admin only. Returns `TokenList`. |
|
||||
| `revoke_token(name)` | `DELETE /admin/tokens/{name}` | Admin only. |
|
||||
| `new_session(opts)` | `POST /sessions` | v0.2. Returns `Session`. |
|
||||
| `list_sessions()` | `GET /sessions` | v0.2. Returns `SessionList`. |
|
||||
| `get_session(id)` | `GET /sessions/{id}` | v0.2. Returns `SessionState`. |
|
||||
|
||||
### `RunResult` helpers
|
||||
|
||||
```rust
|
||||
let r = client.run(req).await?;
|
||||
|
||||
// Try a typed shape.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Recipe { name: String, qty: u32 }
|
||||
let recipe: Recipe = r.as_json()?;
|
||||
|
||||
// Or fall back to a string when the model declined to emit JSON.
|
||||
if let Some(text) = r.as_text() {
|
||||
println!("{text}");
|
||||
}
|
||||
```
|
||||
|
||||
`r.result` itself is `serde_json::Value` if you need to branch on shape.
|
||||
|
||||
### Error model
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
Auth(String), // missing/invalid bearer, 401, 403
|
||||
Api { status: u16, body: String }, // any other non-2xx
|
||||
Transport(reqwest::Error), // connect, TLS, read, request timeout
|
||||
Json(serde_json::Error), // decode failure on a 2xx body
|
||||
Io(std::io::Error), // local file open in upload_file
|
||||
Timeout(String), // explicit deadline (reserved)
|
||||
Config(String), // builder misconfiguration
|
||||
}
|
||||
```
|
||||
|
||||
A 502 from `/run` lands in `Error::Api { status: 502, body }` — the body is
|
||||
the JSON failure envelope. Recover the structured form with:
|
||||
|
||||
```rust
|
||||
let parsed: clawdforge::RunFailure = serde_json::from_str(&body)?;
|
||||
```
|
||||
|
||||
## Wire format
|
||||
|
||||
clawdforge speaks **snake_case JSON** end-to-end. The structs in this crate
|
||||
match that without `#[serde(rename_all = "camelCase")]`. If a future endpoint
|
||||
exposes camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket
|
||||
container attribute so both styles can coexist.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
CLAWDFORGE_URL=http://localhost:8800 \
|
||||
CLAWDFORGE_TOKEN=cf_xxxx \
|
||||
cargo run --example basic
|
||||
```
|
||||
|
||||
Optional file demo:
|
||||
|
||||
```sh
|
||||
CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
cargo test --all
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo build --examples
|
||||
```
|
||||
|
||||
Tests use [`wiremock`](https://docs.rs/wiremock) — no live clawdforge needed.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
77
vendor/clawdforge/examples/basic.rs
vendored
Normal file
77
vendor/clawdforge/examples/basic.rs
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! End-to-end usage example.
|
||||
//!
|
||||
//! Run against a live clawdforge:
|
||||
//!
|
||||
//! ```sh
|
||||
//! CLAWDFORGE_URL=http://localhost:8800 \
|
||||
//! CLAWDFORGE_TOKEN=cf_xxxxxxxxxxxxxxxx \
|
||||
//! cargo run --example basic
|
||||
//! ```
|
||||
|
||||
use clawdforge::{Client, RunRequest};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Hello {
|
||||
hello: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url =
|
||||
std::env::var("CLAWDFORGE_URL").unwrap_or_else(|_| "http://localhost:8800".to_string());
|
||||
let token = std::env::var("CLAWDFORGE_TOKEN")
|
||||
.map_err(|_| "set CLAWDFORGE_TOKEN to a cf_... bearer minted via /admin/tokens")?;
|
||||
|
||||
let client = Client::builder().base_url(url).token(token).build()?;
|
||||
|
||||
// 1) liveness
|
||||
let h = client.healthz().await?;
|
||||
println!(
|
||||
"healthz: ok={} claude_present={} version={:?}",
|
||||
h.ok, h.claude_present, h.claude_version
|
||||
);
|
||||
|
||||
// 2) JSON-shaped run
|
||||
let r = client
|
||||
.run(RunRequest {
|
||||
prompt: r#"Reply with JSON: {"hello": "world"}"#.into(),
|
||||
model: Some("sonnet".into()),
|
||||
timeout_secs: Some(30),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
println!(
|
||||
"duration_ms={} stop_reason={:?}",
|
||||
r.duration_ms, r.stop_reason
|
||||
);
|
||||
|
||||
match r.as_json::<Hello>() {
|
||||
Ok(v) => println!("parsed: hello = {}", v.hello),
|
||||
Err(_) => match r.as_text() {
|
||||
Some(t) => println!("text reply: {t}"),
|
||||
None => println!("unparseable reply: {:?}", r.result),
|
||||
},
|
||||
}
|
||||
|
||||
// 3) optional file upload — only if a path is given.
|
||||
if let Ok(path) = std::env::var("CLAWDFORGE_DEMO_FILE") {
|
||||
let ft = client.upload_file(&path, Some(3600)).await?;
|
||||
println!(
|
||||
"uploaded {} bytes -> {} (ttl {}s)",
|
||||
ft.size, ft.file_token, ft.ttl_secs
|
||||
);
|
||||
|
||||
let r2 = client
|
||||
.run(RunRequest {
|
||||
prompt: "Describe the attached file in one sentence.".into(),
|
||||
files: Some(vec![ft.file_token]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
println!("file-run reply: {:?}", r2.result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
515
vendor/clawdforge/src/client.rs
vendored
Normal file
515
vendor/clawdforge/src/client.rs
vendored
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
//! HTTP client for clawdforge.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::{Body, Method, Response, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use url::Url;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::session::{
|
||||
Session, SessionCloseResponse, SessionCreateResponse, SessionList, SessionOptions,
|
||||
SessionState, TurnResult,
|
||||
};
|
||||
use crate::types::{
|
||||
AppToken, FileToken, Healthz, RunRequest, RunResult, TokenCreateRequest, TokenList,
|
||||
};
|
||||
|
||||
/// Default request timeout if neither the builder nor the per-call helper sets
|
||||
/// one. 120 s leaves headroom over the server's default 60 s `claude` timeout
|
||||
/// without making `/healthz` callers wait forever on a dead host.
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Async client for the clawdforge HTTP API.
|
||||
///
|
||||
/// Construct via [`Client::builder`]. `Client` is cheap to clone — internally
|
||||
/// it wraps an `Arc`-backed `reqwest::Client`.
|
||||
///
|
||||
/// `Debug` is hand-written to redact bearer tokens — `format!("{:?}", client)`
|
||||
/// will never expose `app_token` or `admin_token` plaintext.
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
inner: reqwest::Client,
|
||||
base: Url,
|
||||
/// App-level bearer (used for `/run`, `/files`, `/healthz`). Optional so
|
||||
/// admin-only callers don't have to mint a worthless app token.
|
||||
app_token: Option<String>,
|
||||
/// Admin bootstrap token (used for `/admin/*`). Optional.
|
||||
admin_token: Option<String>,
|
||||
/// Optional cap on `upload_file` size in bytes — `None` = no cap.
|
||||
max_upload_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Client {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Client")
|
||||
.field("base_url", &self.base.as_str())
|
||||
.field("app_token", &self.app_token.as_ref().map(|_| "<redacted>"))
|
||||
.field(
|
||||
"admin_token",
|
||||
&self.admin_token.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("max_upload_bytes", &self.max_upload_bytes)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`Client`].
|
||||
///
|
||||
/// `Debug` is hand-written to redact bearer tokens.
|
||||
#[derive(Default)]
|
||||
pub struct ClientBuilder {
|
||||
base_url: Option<String>,
|
||||
app_token: Option<String>,
|
||||
admin_token: Option<String>,
|
||||
timeout: Option<Duration>,
|
||||
user_agent: Option<String>,
|
||||
danger_accept_invalid_certs: bool,
|
||||
max_upload_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ClientBuilder {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ClientBuilder")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("app_token", &self.app_token.as_ref().map(|_| "<redacted>"))
|
||||
.field(
|
||||
"admin_token",
|
||||
&self.admin_token.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("timeout", &self.timeout)
|
||||
.field("user_agent", &self.user_agent)
|
||||
.field(
|
||||
"danger_accept_invalid_certs",
|
||||
&self.danger_accept_invalid_certs,
|
||||
)
|
||||
.field("max_upload_bytes", &self.max_upload_bytes)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Start building a client.
|
||||
pub fn builder() -> ClientBuilder {
|
||||
ClientBuilder::default()
|
||||
}
|
||||
|
||||
/// Base URL the client was configured with (trailing slash trimmed).
|
||||
pub fn base_url(&self) -> &str {
|
||||
self.base.as_str().trim_end_matches('/')
|
||||
}
|
||||
|
||||
// ---- public API --------------------------------------------------------
|
||||
|
||||
/// `GET /healthz`. Does not require an app token, but the server still
|
||||
/// enforces the global IP allowlist.
|
||||
pub async fn healthz(&self) -> Result<Healthz, Error> {
|
||||
let url = self.url("/healthz")?;
|
||||
let req = self.inner.request(Method::GET, url);
|
||||
// healthz works without a token, but if we have one, send it — the
|
||||
// server treats it as a no-op.
|
||||
let req = match &self.app_token {
|
||||
Some(t) => req.bearer_auth(t),
|
||||
None => req,
|
||||
};
|
||||
let resp = req.send().await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `POST /run`. Returns the parsed [`RunResult`] on success. On HTTP 502
|
||||
/// the body is surfaced as [`Error::Api`] with `status = 502` and the
|
||||
/// failure JSON in `body` — see [`crate::types::RunFailure`] for the
|
||||
/// structured form.
|
||||
pub async fn run(&self, body: RunRequest) -> Result<RunResult, Error> {
|
||||
let token = self
|
||||
.app_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
|
||||
let url = self.url("/run")?;
|
||||
let resp = self
|
||||
.inner
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `POST /files`. Streams the file from disk via `tokio::fs::File`; large
|
||||
/// uploads do not buffer fully in memory.
|
||||
///
|
||||
/// `ttl_secs` defaults to the server's 3600 if `None`. Server clamps to
|
||||
/// `60..=86400`.
|
||||
///
|
||||
/// If [`ClientBuilder::max_upload_bytes`] was set and the file's size on
|
||||
/// disk exceeds it, returns [`Error::Config`] before opening any network
|
||||
/// connection.
|
||||
pub async fn upload_file(
|
||||
&self,
|
||||
path: impl AsRef<Path>,
|
||||
ttl_secs: Option<u32>,
|
||||
) -> Result<FileToken, Error> {
|
||||
let token = self
|
||||
.app_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
|
||||
let path = path.as_ref();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("upload")
|
||||
.to_string();
|
||||
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
let len = file.metadata().await?.len();
|
||||
|
||||
if let Some(max) = self.max_upload_bytes {
|
||||
if len > max {
|
||||
return Err(Error::Config(format!(
|
||||
"file size {len} bytes exceeds max_upload_bytes={max}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::wrap_stream(stream);
|
||||
|
||||
let part = Part::stream_with_length(body, len)
|
||||
.file_name(file_name)
|
||||
.mime_str("application/octet-stream")
|
||||
.map_err(|e| Error::Config(format!("invalid mime: {e}")))?;
|
||||
|
||||
let mut form = Form::new().part("file", part);
|
||||
if let Some(t) = ttl_secs {
|
||||
form = form.text("ttl_secs", t.to_string());
|
||||
}
|
||||
|
||||
let url = self.url("/files")?;
|
||||
let resp = self
|
||||
.inner
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `POST /admin/tokens`. Requires an admin token on the client.
|
||||
pub async fn create_token(&self, body: TokenCreateRequest) -> Result<AppToken, Error> {
|
||||
let token = self.require_admin()?;
|
||||
let url = self.url("/admin/tokens")?;
|
||||
let resp = self
|
||||
.inner
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `GET /admin/tokens`. Requires an admin token on the client.
|
||||
pub async fn list_tokens(&self) -> Result<TokenList, Error> {
|
||||
let token = self.require_admin()?;
|
||||
let url = self.url("/admin/tokens")?;
|
||||
let resp = self.inner.get(url).bearer_auth(token).send().await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /admin/tokens/{name}`. Requires an admin token on the client.
|
||||
/// Returns `Ok(())` on success, [`Error::Api`] with status 404 if the
|
||||
/// token does not exist.
|
||||
///
|
||||
/// `name` is validated client-side to match the server's
|
||||
/// `[a-z0-9][a-z0-9_-]{0,63}` constraint — anything containing `/`, `?`,
|
||||
/// `#`, `..`, or empty short-circuits with [`Error::Config`] before a
|
||||
/// request is sent. This is defense-in-depth against path traversal via
|
||||
/// `Url::join` (which honors RFC 3986 `..` resolution).
|
||||
pub async fn revoke_token(&self, name: &str) -> Result<(), Error> {
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| name.contains('?')
|
||||
|| name.contains('#')
|
||||
|| name.contains("..")
|
||||
{
|
||||
return Err(Error::Config(format!("invalid token name: {name:?}")));
|
||||
}
|
||||
let token = self.require_admin()?;
|
||||
let url = self.url(&format!("/admin/tokens/{name}"))?;
|
||||
let resp = self.inner.delete(url).bearer_auth(token).send().await?;
|
||||
// 2xx is success regardless of body — RFC-correct DELETE may return
|
||||
// 204 No Content with no body.
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
// Non-2xx: route through json_or_error to get Auth/Api mapping.
|
||||
// Discard the (already-non-success) deserialization slot.
|
||||
let _: serde_json::Value = json_or_error(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- v0.2 multi-turn / sessions ---------------------------------------
|
||||
|
||||
/// `POST /sessions`. Create a new multi-turn session and return a
|
||||
/// [`Session`] handle bound to this client.
|
||||
///
|
||||
/// The handle owns a clone of the client; dropping it without an explicit
|
||||
/// `Session::close().await?` triggers a best-effort async DELETE via
|
||||
/// `tokio::spawn`. See [`Session`] for the full lifecycle contract.
|
||||
pub async fn new_session(&self, opts: SessionOptions) -> Result<Session, Error> {
|
||||
let token = self.require_app()?;
|
||||
let url = self.url("/sessions")?;
|
||||
let resp = self
|
||||
.inner
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.json(&opts)
|
||||
.send()
|
||||
.await?;
|
||||
let created: SessionCreateResponse = json_or_error(resp).await?;
|
||||
Ok(Session {
|
||||
client: self.clone(),
|
||||
session_id: created.session_id,
|
||||
agent: created.agent,
|
||||
created_at: created.created_at,
|
||||
closed: std::sync::atomic::AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET /sessions`. List all sessions visible to the calling app token.
|
||||
pub async fn list_sessions(&self) -> Result<SessionList, Error> {
|
||||
let token = self.require_app()?;
|
||||
let url = self.url("/sessions")?;
|
||||
let resp = self.inner.get(url).bearer_auth(token).send().await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// `GET /sessions/{id}`. Fetch the current state of a session.
|
||||
///
|
||||
/// `id` is validated client-side against the same path-traversal guard as
|
||||
/// [`Self::revoke_token`] — anything containing `/`, `?`, `#`, `..`, or
|
||||
/// empty short-circuits with [`Error::Config`].
|
||||
pub async fn get_session(&self, id: &str) -> Result<SessionState, Error> {
|
||||
validate_session_id(id)?;
|
||||
let token = self.require_app()?;
|
||||
let url = self.url(&format!("/sessions/{id}"))?;
|
||||
let resp = self.inner.get(url).bearer_auth(token).send().await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
/// Internal helper used by both [`Session::close`] and [`Session`]'s
|
||||
/// `Drop` impl to issue `DELETE /sessions/{id}`.
|
||||
pub(crate) async fn close_session_internal(&self, id: &str) -> Result<(), Error> {
|
||||
validate_session_id(id)?;
|
||||
let token = self.require_app()?;
|
||||
let url = self.url(&format!("/sessions/{id}"))?;
|
||||
let resp = self.inner.delete(url).bearer_auth(token).send().await?;
|
||||
if resp.status().is_success() {
|
||||
// Body is informational — `{ ok, already_closed? }`. Drain and
|
||||
// ignore decode failure (server may legitimately 204).
|
||||
let bytes = resp.bytes().await?;
|
||||
if !bytes.is_empty() {
|
||||
let _ = serde_json::from_slice::<SessionCloseResponse>(&bytes);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Funnel non-2xx through json_or_error for Auth/Api mapping.
|
||||
let _: serde_json::Value = json_or_error(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Internal helper used by [`Session::turn`] /
|
||||
/// [`Session::turn_with_files`] to dispatch to
|
||||
/// `POST /sessions/{id}/turn`.
|
||||
pub(crate) async fn turn_internal<B: serde::Serialize>(
|
||||
&self,
|
||||
id: &str,
|
||||
body: &B,
|
||||
) -> Result<TurnResult, Error> {
|
||||
validate_session_id(id)?;
|
||||
let token = self.require_app()?;
|
||||
let url = self.url(&format!("/sessions/{id}/turn"))?;
|
||||
let resp = self
|
||||
.inner
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
json_or_error(resp).await
|
||||
}
|
||||
|
||||
// ---- internal ----------------------------------------------------------
|
||||
|
||||
fn require_app(&self) -> Result<&str, Error> {
|
||||
self.app_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Auth("no app token configured".into()))
|
||||
}
|
||||
|
||||
fn require_admin(&self) -> Result<&str, Error> {
|
||||
self.admin_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Auth("no admin token configured".into()))
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> Result<Url, Error> {
|
||||
let trimmed = path.strip_prefix('/').unwrap_or(path);
|
||||
self.base
|
||||
.join(trimmed)
|
||||
.map_err(|e| Error::Config(format!("bad path {path:?}: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientBuilder {
|
||||
/// Set the base URL (e.g. `http://localhost:8800`). Required.
|
||||
pub fn base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the app bearer token used for `/run`, `/files`, and `/healthz`.
|
||||
pub fn token(mut self, token: impl Into<String>) -> Self {
|
||||
self.app_token = Some(token.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the admin bootstrap token used for `/admin/*`. May be set
|
||||
/// alongside [`Self::token`].
|
||||
pub fn admin_token(mut self, token: impl Into<String>) -> Self {
|
||||
self.admin_token = Some(token.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-request timeout for the underlying `reqwest::Client`. Defaults to
|
||||
/// 120 s.
|
||||
pub fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the `User-Agent` header. Defaults to
|
||||
/// `clawdforge-rs/<crate-version>`.
|
||||
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
|
||||
self.user_agent = Some(ua.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Skip TLS certificate verification. Off by default. Only useful against
|
||||
/// self-signed local deployments.
|
||||
pub fn danger_accept_invalid_certs(mut self, enable: bool) -> Self {
|
||||
self.danger_accept_invalid_certs = enable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum file size (in bytes) accepted by [`Client::upload_file`].
|
||||
/// Files exceeding this cap fail with [`Error::Config`] before any
|
||||
/// network I/O. Default `None` = no client-side cap (the server's own
|
||||
/// limit still applies).
|
||||
pub fn max_upload_bytes(mut self, max: u64) -> Self {
|
||||
self.max_upload_bytes = Some(max);
|
||||
self
|
||||
}
|
||||
|
||||
/// Finalize. Errors if `base_url` is missing or unparseable.
|
||||
pub fn build(self) -> Result<Client, Error> {
|
||||
let base_raw = self
|
||||
.base_url
|
||||
.ok_or_else(|| Error::Config("base_url is required".into()))?;
|
||||
// Ensure trailing slash so `Url::join` treats the base as a directory.
|
||||
let base_str = if base_raw.ends_with('/') {
|
||||
base_raw
|
||||
} else {
|
||||
format!("{base_raw}/")
|
||||
};
|
||||
let base =
|
||||
Url::parse(&base_str).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?;
|
||||
if !matches!(base.scheme(), "http" | "https") {
|
||||
return Err(Error::Config(format!(
|
||||
"unsupported scheme: {}",
|
||||
base.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
let ua = self
|
||||
.user_agent
|
||||
.unwrap_or_else(|| format!("clawdforge-rs/{}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
// We don't preset Authorization here — per-call helpers do it because
|
||||
// the right token depends on which endpoint is being hit.
|
||||
|
||||
let inner = reqwest::Client::builder()
|
||||
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
|
||||
.user_agent(ua)
|
||||
.default_headers(headers)
|
||||
.danger_accept_invalid_certs(self.danger_accept_invalid_certs)
|
||||
.build()?;
|
||||
|
||||
Ok(Client {
|
||||
inner,
|
||||
base,
|
||||
app_token: self.app_token,
|
||||
admin_token: self.admin_token,
|
||||
max_upload_bytes: self.max_upload_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode `T` from a successful 2xx response, otherwise lift to [`Error`].
|
||||
async fn json_or_error<T: DeserializeOwned>(resp: Response) -> Result<T, Error> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let bytes = resp.bytes().await?;
|
||||
return Ok(serde_json::from_slice::<T>(&bytes)?);
|
||||
}
|
||||
|
||||
// Non-2xx: capture body lossily then translate.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Auth(format!(
|
||||
"{}: {}",
|
||||
status.as_u16(),
|
||||
truncate(&body, 500)
|
||||
))),
|
||||
_ => Err(Error::api(status.as_u16(), body)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Defense-in-depth path validator for session ids in `/sessions/{id}*`. Same
|
||||
/// shape as [`Client::revoke_token`]'s name guard — RFC 3986 dot-segment
|
||||
/// resolution inside `Url::join` is the threat model here.
|
||||
fn validate_session_id(id: &str) -> Result<(), Error> {
|
||||
if id.is_empty()
|
||||
|| id.contains('/')
|
||||
|| id.contains('?')
|
||||
|| id.contains('#')
|
||||
|| id.contains("..")
|
||||
{
|
||||
return Err(Error::Config(format!("invalid session id: {id:?}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Truncate `s` to at most `max` bytes, snapping down to the nearest UTF-8
|
||||
/// codepoint boundary so we never panic on multibyte sequences. Appends `…`
|
||||
/// to the truncated form. `str::floor_char_boundary` (stable 1.80+) does the
|
||||
/// boundary math.
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let safe = s.floor_char_boundary(max);
|
||||
format!("{}…", &s[..safe])
|
||||
}
|
||||
}
|
||||
68
vendor/clawdforge/src/error.rs
vendored
Normal file
68
vendor/clawdforge/src/error.rs
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! Error types for the clawdforge client.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors surfaced by the client.
|
||||
///
|
||||
/// Variants are deliberately coarse — the underlying transport / serde errors
|
||||
/// are preserved as `source()` for callers who want to dig in.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// 401/403 from the server, or a missing/empty bearer token configured on
|
||||
/// the client.
|
||||
#[error("authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// Any non-2xx response that wasn't an auth error. The response body is
|
||||
/// captured as a UTF-8 string (lossy if the server returned binary).
|
||||
#[error("api error: status={status} body={body}")]
|
||||
Api {
|
||||
/// HTTP status code.
|
||||
status: u16,
|
||||
/// Response body (best-effort UTF-8).
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// Request never completed: DNS, connect, TLS, body-read, etc.
|
||||
#[error("transport error: {0}")]
|
||||
Transport(reqwest::Error),
|
||||
|
||||
/// JSON decode failed on a successful HTTP response.
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// Local I/O error — currently only emitted by `upload_file` when opening
|
||||
/// the source file.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// The configured request timeout elapsed before the server replied.
|
||||
/// Mapped from `reqwest::Error::is_timeout()` so callers can match on
|
||||
/// timeouts specifically without inspecting the inner transport error.
|
||||
#[error("timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// Misconfigured client (e.g. invalid base URL).
|
||||
#[error("invalid configuration: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
if e.is_timeout() {
|
||||
Self::Timeout(e.to_string())
|
||||
} else {
|
||||
Self::Transport(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Build an [`Error::Api`] from a status code and body string.
|
||||
pub(crate) fn api(status: u16, body: impl Into<String>) -> Self {
|
||||
Self::Api {
|
||||
status,
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
58
vendor/clawdforge/src/lib.rs
vendored
Normal file
58
vendor/clawdforge/src/lib.rs
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Async Rust client for the [clawdforge] HTTP service.
|
||||
//!
|
||||
//! clawdforge is a small LAN-only service that wraps `claude -p` subprocess
|
||||
//! calls behind a bearer-token-gated REST API. This crate is a thin,
|
||||
//! ergonomic Rust SDK for it.
|
||||
//!
|
||||
//! # Quickstart
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use clawdforge::{Client, RunRequest};
|
||||
//!
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let client = Client::builder()
|
||||
//! .base_url("http://localhost:8800")
|
||||
//! .token("cf_xxxxxxxxxxxxxxxx")
|
||||
//! .build()?;
|
||||
//!
|
||||
//! let h = client.healthz().await?;
|
||||
//! println!("claude present: {}", h.claude_present);
|
||||
//!
|
||||
//! let r = client.run(RunRequest {
|
||||
//! prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
|
||||
//! model: Some("sonnet".into()),
|
||||
//! ..Default::default()
|
||||
//! }).await?;
|
||||
//!
|
||||
//! #[derive(serde::Deserialize)]
|
||||
//! struct Hello { hello: String }
|
||||
//! let typed: Hello = r.as_json()?;
|
||||
//! println!("{}", typed.hello);
|
||||
//! # Ok(()) }
|
||||
//! ```
|
||||
//!
|
||||
//! [clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
|
||||
//!
|
||||
//! # Field naming
|
||||
//!
|
||||
//! The clawdforge wire format is snake_case end-to-end (Python / Pydantic
|
||||
//! conventions), so structs in [`crate::types`] do **not** carry
|
||||
//! `#[serde(rename_all = "camelCase")]`. If a future endpoint exposes
|
||||
//! camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket
|
||||
//! container attribute.
|
||||
|
||||
#![deny(rust_2018_idioms)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
pub mod session;
|
||||
pub mod types;
|
||||
|
||||
pub use client::{Client, ClientBuilder};
|
||||
pub use error::Error;
|
||||
pub use session::{Session, SessionList, SessionOptions, SessionState, TurnEvent, TurnResult};
|
||||
pub use types::{
|
||||
AppToken, AppTokenInfo, FileToken, Healthz, RunFailure, RunRequest, RunResult,
|
||||
TokenCreateRequest, TokenList,
|
||||
};
|
||||
281
vendor/clawdforge/src/session.rs
vendored
Normal file
281
vendor/clawdforge/src/session.rs
vendored
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
//! Multi-turn session API (v0.2).
|
||||
//!
|
||||
//! v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A
|
||||
//! [`Session`] is a handle to one server-side session; [`Session::turn`]
|
||||
//! dispatches a single prompt+files turn and returns the structured event
|
||||
//! batch. Sessions are explicitly closed via [`Session::close`] (consumes the
|
||||
//! handle, preventing use-after-close at compile time) or — as a last-resort
|
||||
//! fallback — best-effort closed by [`Drop`] via `tokio::spawn`.
|
||||
//!
|
||||
//! v0.1 single-turn `Client::run` is unchanged; the v0.2 surface is purely
|
||||
//! additive.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::error::Error;
|
||||
|
||||
/// Options passed to [`Client::new_session`].
|
||||
///
|
||||
/// `Default` produces `agent = None` (server picks `"claude"`) and `meta =
|
||||
/// None`.
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct SessionOptions {
|
||||
/// Agent slug to dispatch to. `None` falls back to the server-side default
|
||||
/// (`"claude"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<String>,
|
||||
|
||||
/// Free-form metadata stored alongside the session ledger row.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Reply body from `POST /sessions`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SessionCreateResponse {
|
||||
pub session_id: String,
|
||||
pub agent: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// One event in a turn's structured output.
|
||||
///
|
||||
/// `event_type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. (server
|
||||
/// is the authority on the set).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TurnEvent {
|
||||
/// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...).
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
/// Text content for `"text"` and `"thinking"` events.
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
/// Tool name for `"tool_call"` events.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Tool arguments for `"tool_call"` events.
|
||||
#[serde(default)]
|
||||
pub args: Option<serde_json::Value>,
|
||||
/// Tool result for `"tool_call"` events.
|
||||
#[serde(default)]
|
||||
pub result: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Successful response body from `POST /sessions/{id}/turn`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TurnResult {
|
||||
/// Always `true` on a 200 reply.
|
||||
pub ok: bool,
|
||||
/// The session this turn belongs to.
|
||||
pub session_id: String,
|
||||
/// 1-based index of this turn within the session.
|
||||
pub turn_index: i32,
|
||||
/// Structured events emitted during the turn.
|
||||
pub events: Vec<TurnEvent>,
|
||||
/// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...).
|
||||
pub stop_reason: String,
|
||||
/// Wall-clock duration of the turn.
|
||||
pub duration_ms: i64,
|
||||
}
|
||||
|
||||
impl TurnResult {
|
||||
/// Concatenate all `"text"` event contents into a single string.
|
||||
///
|
||||
/// Non-text events (`thinking`, `tool_call`, ...) are skipped. If an event
|
||||
/// is `"text"` but `content` is `None`, it contributes the empty string.
|
||||
pub fn text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for ev in &self.events {
|
||||
if ev.event_type == "text" {
|
||||
if let Some(c) = ev.content.as_deref() {
|
||||
out.push_str(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SessionState {
|
||||
/// Server-issued session id.
|
||||
pub session_id: String,
|
||||
/// Agent slug bound to the session.
|
||||
pub agent: String,
|
||||
/// App / consumer name that owns the session.
|
||||
pub app_name: String,
|
||||
/// Unix epoch seconds when created.
|
||||
pub created_at: i64,
|
||||
/// Unix epoch seconds of the last successful turn (or `None` if zero turns
|
||||
/// have been dispatched yet).
|
||||
#[serde(default)]
|
||||
pub last_turn_at: Option<i64>,
|
||||
/// Number of turns dispatched.
|
||||
pub turn_count: i32,
|
||||
/// Unix epoch seconds when closed (or `None` if still open).
|
||||
#[serde(default)]
|
||||
pub closed_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Reply body from `DELETE /sessions/{id}`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct SessionCloseResponse {
|
||||
#[allow(dead_code)]
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub already_closed: Option<bool>,
|
||||
}
|
||||
|
||||
/// Reply body from `GET /sessions`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SessionList {
|
||||
/// All sessions visible to the calling token.
|
||||
pub sessions: Vec<SessionState>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /sessions/{id}/turn`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TurnRequest<'a> {
|
||||
prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
files: Option<&'a [String]>,
|
||||
}
|
||||
|
||||
/// A handle to one server-side multi-turn session.
|
||||
///
|
||||
/// Construct via [`Client::new_session`]. Drop or [`Session::close`] to
|
||||
/// release the server-side session. `close` consumes the value so use-after-
|
||||
/// close is a compile error; `Drop` is a best-effort backstop that fires an
|
||||
/// async DELETE via `tokio::spawn` and logs (does not panic) on failure.
|
||||
///
|
||||
/// `Debug` is hand-written and explicitly excludes the embedded [`Client`] so
|
||||
/// no bearer can leak through `{:?}` formatting.
|
||||
pub struct Session {
|
||||
pub(crate) client: Client,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) agent: String,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Session {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Deliberately omit `client` — its Debug already redacts tokens, but
|
||||
// the spec-mandated shape is `Session { session_id, agent, closed }`
|
||||
// to keep the surface minimal and audit-friendly.
|
||||
f.debug_struct("Session")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("agent", &self.agent)
|
||||
.field("created_at", &self.created_at)
|
||||
.field("closed", &self.closed.load(Ordering::Acquire))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Server-issued session id.
|
||||
pub fn id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// Agent slug the server bound to this session.
|
||||
pub fn agent(&self) -> &str {
|
||||
&self.agent
|
||||
}
|
||||
|
||||
/// Unix epoch seconds when the session was created server-side.
|
||||
pub fn created_at(&self) -> i64 {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
/// Whether the session has been explicitly closed already. Sessions closed
|
||||
/// only via `Drop`'s spawn are still reported as closed once that future
|
||||
/// has run; this getter reflects the in-memory flag.
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Send a turn with no attached files.
|
||||
///
|
||||
/// Equivalent to `turn_with_files(prompt, &[])` but skips serializing the
|
||||
/// `files` field on the wire.
|
||||
pub async fn turn(&mut self, prompt: impl Into<String>) -> Result<TurnResult, Error> {
|
||||
self.dispatch_turn(prompt.into(), None).await
|
||||
}
|
||||
|
||||
/// Send a turn that references previously uploaded file tokens.
|
||||
///
|
||||
/// `files` is the list of `ff_*` tokens returned by [`Client::upload_file`].
|
||||
///
|
||||
/// [`Client::upload_file`]: crate::Client::upload_file
|
||||
pub async fn turn_with_files(
|
||||
&mut self,
|
||||
prompt: impl Into<String>,
|
||||
files: &[String],
|
||||
) -> Result<TurnResult, Error> {
|
||||
self.dispatch_turn(prompt.into(), Some(files)).await
|
||||
}
|
||||
|
||||
async fn dispatch_turn(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
files: Option<&[String]>,
|
||||
) -> Result<TurnResult, Error> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(Error::Config("session is closed".into()));
|
||||
}
|
||||
self.client
|
||||
.turn_internal(&self.session_id, &TurnRequest { prompt, files })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Explicitly close the session. Consumes `self` — use-after-close is a
|
||||
/// compile error.
|
||||
///
|
||||
/// If the session is already closed in memory (e.g. via a prior failed
|
||||
/// close or a prior dispatch path that flagged it), this short-circuits
|
||||
/// without contacting the server.
|
||||
pub async fn close(self) -> Result<(), Error> {
|
||||
// Mark closed before the network call so a panic-mid-await on the
|
||||
// request future cannot trigger Drop's spawn into a double-close.
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
self.client.close_session_internal(&self.session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
// If close() already ran, nothing to do.
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
// tokio::spawn panics if no runtime is current. Guard against being
|
||||
// dropped from a sync context (e.g. a forgotten value at the end of a
|
||||
// sync `main`).
|
||||
if tokio::runtime::Handle::try_current().is_err() {
|
||||
tracing::warn!(
|
||||
session_id = %self.session_id,
|
||||
"Session dropped outside a tokio runtime; server-side session not closed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let client = self.client.clone();
|
||||
let id = self.session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.close_session_internal(&id).await {
|
||||
tracing::warn!(
|
||||
session_id = %id,
|
||||
error = %e,
|
||||
"best-effort drop close failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
186
vendor/clawdforge/src/types.rs
vendored
Normal file
186
vendor/clawdforge/src/types.rs
vendored
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
//! Wire types matching the clawdforge HTTP API.
|
||||
//!
|
||||
//! Field naming note: clawdforge uses snake_case on the wire (matches Python /
|
||||
//! Pydantic conventions), so these structs use plain `#[derive(Serialize,
|
||||
//! Deserialize)]` without `rename_all`. If a future endpoint surfaces
|
||||
//! camelCase, opt in per-field with `#[serde(rename = "...")]`.
|
||||
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// `GET /healthz` response body.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Healthz {
|
||||
/// Always `true` if the server replied.
|
||||
pub ok: bool,
|
||||
/// Whether the `claude` binary was discovered on `PATH`.
|
||||
pub claude_present: bool,
|
||||
/// First line of `claude --version` (or `null` if not present).
|
||||
pub claude_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /run`.
|
||||
///
|
||||
/// All optional fields default to `None` — use `..Default::default()` to fill
|
||||
/// the rest:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use clawdforge::RunRequest;
|
||||
/// let r = RunRequest {
|
||||
/// prompt: "say hi".into(),
|
||||
/// model: Some("sonnet".into()),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RunRequest {
|
||||
/// Prompt text. Must be non-empty server-side.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model alias passed to `claude -p --model`. `None` falls back to the
|
||||
/// server-side default (typically `sonnet`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// System prompt appended via `claude -p --append-system-prompt`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
|
||||
/// File tokens previously returned from [`Client::upload_file`].
|
||||
///
|
||||
/// [`Client::upload_file`]: crate::Client::upload_file
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub files: Option<Vec<String>>,
|
||||
|
||||
/// Subprocess timeout in seconds. Server clamps to `5..=600`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// Successful response body from `POST /run`.
|
||||
///
|
||||
/// `result` is intentionally a [`serde_json::Value`] — clawdforge auto-parses
|
||||
/// the `claude` reply as JSON when possible and falls back to a raw string
|
||||
/// otherwise. Use [`RunResult::as_json`] to deserialize into a typed struct
|
||||
/// or [`RunResult::as_text`] when you expect a string.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RunResult {
|
||||
/// Always `true` for a 200 response (the server returns 502 on failure).
|
||||
pub ok: bool,
|
||||
/// Parsed claude output. JSON object/array/number/bool when the model
|
||||
/// emitted JSON; string otherwise.
|
||||
pub result: serde_json::Value,
|
||||
/// Wall-clock duration of the subprocess.
|
||||
pub duration_ms: u64,
|
||||
/// `claude` stop reason, e.g. `"end_turn"`. Sometimes `None` on edge cases.
|
||||
pub stop_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl RunResult {
|
||||
/// Deserialize `result` as `T`. Fails with [`Error::Json`] if the server
|
||||
/// returned a string or a JSON shape that doesn't match `T`.
|
||||
pub fn as_json<T: DeserializeOwned>(&self) -> Result<T, Error> {
|
||||
Ok(serde_json::from_value(self.result.clone())?)
|
||||
}
|
||||
|
||||
/// Borrow `result` as a string slice if it was a JSON string.
|
||||
/// Returns `None` for objects, arrays, numbers, etc.
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
self.result.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure body from `POST /run` (HTTP 502).
|
||||
///
|
||||
/// Surfaced inside [`Error::Api`] via the `body` field as JSON text. Provided
|
||||
/// here so callers can `serde_json::from_str::<RunFailure>(&body)` to recover
|
||||
/// structured data.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RunFailure {
|
||||
/// Always `false` for a failure body.
|
||||
pub ok: bool,
|
||||
/// Short error label set by the runner (e.g. `"timeout"`, `"non_zero_exit"`).
|
||||
pub error: Option<String>,
|
||||
/// Last 4 KB of `claude` stderr, when available.
|
||||
pub stderr: Option<String>,
|
||||
/// Wall-clock duration of the (failed) subprocess.
|
||||
pub duration_ms: u64,
|
||||
/// `claude` stop reason if the failure produced one.
|
||||
pub stop_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Response body from `POST /files`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct FileToken {
|
||||
/// Opaque token, prefix `ff_`. Pass to [`RunRequest::files`].
|
||||
pub file_token: String,
|
||||
/// TTL the server registered (clamped to 60..=86400).
|
||||
pub ttl_secs: u32,
|
||||
/// Bytes written to the staging dir.
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Response body from `POST /admin/tokens`.
|
||||
///
|
||||
/// `token` is the plaintext bearer — only returned at creation time.
|
||||
///
|
||||
/// `Debug` is hand-written to redact `token` (the plaintext bearer); `name`
|
||||
/// and `ip_cidrs` print verbatim.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct AppToken {
|
||||
/// App / consumer name.
|
||||
pub name: String,
|
||||
/// Plaintext bearer (`cf_...`). Save it now; the server stores only the
|
||||
/// SHA-256.
|
||||
pub token: String,
|
||||
/// CIDRs the token is restricted to. Empty = unrestricted (still subject
|
||||
/// to the global allowlist).
|
||||
pub ip_cidrs: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AppToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AppToken")
|
||||
.field("name", &self.name)
|
||||
.field("token", &"<redacted>")
|
||||
.field("ip_cidrs", &self.ip_cidrs)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for `POST /admin/tokens`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenCreateRequest {
|
||||
/// `[a-z0-9][a-z0-9_-]{0,63}` — server enforces.
|
||||
pub name: String,
|
||||
/// Optional CIDR allowlist for this token.
|
||||
#[serde(default)]
|
||||
pub ip_cidrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// One row of `GET /admin/tokens`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AppTokenInfo {
|
||||
/// App / consumer name.
|
||||
pub name: String,
|
||||
/// CIDRs this token is restricted to. Empty = no per-token CIDR restriction.
|
||||
#[serde(default)]
|
||||
pub ip_cidrs: Vec<String>,
|
||||
/// Unix epoch seconds (server-controlled field shape — extra fields are
|
||||
/// captured by `extra`).
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
|
||||
/// Catch-all for any future fields the server adds.
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response body from `GET /admin/tokens`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TokenList {
|
||||
/// All registered app tokens (hashes only — plaintext is shown once at
|
||||
/// creation).
|
||||
pub tokens: Vec<AppTokenInfo>,
|
||||
}
|
||||
517
vendor/clawdforge/tests/client.rs
vendored
Normal file
517
vendor/clawdforge/tests/client.rs
vendored
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
//! Integration tests against an in-process wiremock server.
|
||||
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use clawdforge::{Client, Error, RunRequest, TokenCreateRequest};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_json, body_string_contains, header, method, path, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_client(server: &MockServer) -> Client {
|
||||
Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token("cf_test_token")
|
||||
.admin_token("admin_test_token")
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("client builds")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_payload() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/healthz"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"claude_present": true,
|
||||
"claude_version": "claude 1.2.3"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let h = c.healthz().await.unwrap();
|
||||
assert!(h.ok);
|
||||
assert!(h.claude_present);
|
||||
assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_success_with_json_result() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.and(body_json(json!({
|
||||
"prompt": "give me json",
|
||||
"model": "sonnet"
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"result": {"hello": "world", "n": 42},
|
||||
"duration_ms": 1234,
|
||||
"stop_reason": "end_turn"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let r = c
|
||||
.run(RunRequest {
|
||||
prompt: "give me json".into(),
|
||||
model: Some("sonnet".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(r.ok);
|
||||
assert_eq!(r.duration_ms, 1234);
|
||||
assert_eq!(r.stop_reason.as_deref(), Some("end_turn"));
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Reply {
|
||||
hello: String,
|
||||
n: i32,
|
||||
}
|
||||
let parsed: Reply = r.as_json().unwrap();
|
||||
assert_eq!(parsed.hello, "world");
|
||||
assert_eq!(parsed.n, 42);
|
||||
assert!(r.as_text().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_success_with_text_result() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"result": "plain string reply",
|
||||
"duration_ms": 50,
|
||||
"stop_reason": "end_turn"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let r = c
|
||||
.run(RunRequest {
|
||||
prompt: "say hi".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.as_text(), Some("plain string reply"));
|
||||
let json_attempt: Result<serde_json::Map<String, serde_json::Value>, _> = r.as_json();
|
||||
assert!(
|
||||
json_attempt.is_err(),
|
||||
"string should not deserialize as map"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_502_surfaces_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.respond_with(ResponseTemplate::new(502).set_body_json(json!({
|
||||
"ok": false,
|
||||
"error": "claude exited 1",
|
||||
"stderr": "boom",
|
||||
"duration_ms": 10,
|
||||
"stop_reason": null
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let err = c
|
||||
.run(RunRequest {
|
||||
prompt: "fail".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
|
||||
match err {
|
||||
Error::Api { status, body } => {
|
||||
assert_eq!(status, 502);
|
||||
assert!(body.contains("claude exited 1"), "body was {body}");
|
||||
// Demonstrate caller-side recovery via RunFailure.
|
||||
let parsed: clawdforge::RunFailure =
|
||||
serde_json::from_str(&body).expect("body is RunFailure JSON");
|
||||
assert!(!parsed.ok);
|
||||
assert_eq!(parsed.error.as_deref(), Some("claude exited 1"));
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_with_files_passes_through() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.and(body_json(json!({
|
||||
"prompt": "use the file",
|
||||
"files": ["ff_abc", "ff_def"]
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"result": "saw 2 files",
|
||||
"duration_ms": 100,
|
||||
"stop_reason": "end_turn"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let r = c
|
||||
.run(RunRequest {
|
||||
prompt: "use the file".into(),
|
||||
files: Some(vec!["ff_abc".into(), "ff_def".into()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.as_text(), Some("saw 2 files"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_streams_multipart() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// wiremock can't easily decode multipart, so we fingerprint the bytes:
|
||||
// the file's contents (as a UTF-8 substring) and the form field names.
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/files"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.and(body_string_contains("hello-from-rust-test"))
|
||||
.and(body_string_contains("name=\"file\""))
|
||||
.and(body_string_contains("name=\"ttl_secs\""))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"file_token": "ff_xyz",
|
||||
"ttl_secs": 1800,
|
||||
"size": 20
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(tmp, "hello-from-rust-test").unwrap();
|
||||
tmp.flush().unwrap();
|
||||
|
||||
let c = make_client(&server);
|
||||
let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap();
|
||||
assert_eq!(ft.file_token, "ff_xyz");
|
||||
assert_eq!(ft.ttl_secs, 1800);
|
||||
assert_eq!(ft.size, 20);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_create_token() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/admin/tokens"))
|
||||
.and(header("authorization", "Bearer admin_test_token"))
|
||||
.and(body_json(json!({
|
||||
"name": "cauldron",
|
||||
"ip_cidrs": ["172.24.0.0/16"]
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"name": "cauldron",
|
||||
"token": "cf_brandnew",
|
||||
"ip_cidrs": ["172.24.0.0/16"]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let t = c
|
||||
.create_token(TokenCreateRequest {
|
||||
name: "cauldron".into(),
|
||||
ip_cidrs: vec!["172.24.0.0/16".into()],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(t.name, "cauldron");
|
||||
assert_eq!(t.token, "cf_brandnew");
|
||||
assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_list_tokens() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/admin/tokens"))
|
||||
.and(header("authorization", "Bearer admin_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"tokens": [
|
||||
{"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000},
|
||||
{"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let list = c.list_tokens().await.unwrap();
|
||||
assert_eq!(list.tokens.len(), 2);
|
||||
assert_eq!(list.tokens[0].name, "cauldron");
|
||||
// unknown server-added field captured by `extra`.
|
||||
assert!(list.tokens[1].extra.contains_key("last_seen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_revoke_token() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path_regex(r"^/admin/tokens/.+"))
|
||||
.and(header("authorization", "Bearer admin_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
c.revoke_token("cauldron").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_response_maps_to_auth_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("missing token"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let err = c
|
||||
.run(RunRequest {
|
||||
prompt: "nope".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
assert!(matches!(err, Error::Auth(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_app_token_short_circuits_run() {
|
||||
// Build a client without an app token but with admin set.
|
||||
let server = MockServer::start().await;
|
||||
let c = Client::builder()
|
||||
.base_url(server.uri())
|
||||
.admin_token("admin_only")
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let err = c
|
||||
.run(RunRequest {
|
||||
prompt: "x".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("should fail without app token");
|
||||
match err {
|
||||
Error::Auth(msg) => assert!(msg.contains("no app token")),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_timeout_constructed_on_reqwest_timeout() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/healthz"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(2_000))
|
||||
.set_body_json(json!({
|
||||
"ok": true,
|
||||
"claude_present": true,
|
||||
"claude_version": "x"
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token("cf_x")
|
||||
.timeout(Duration::from_millis(150))
|
||||
.build()
|
||||
.unwrap();
|
||||
let err = c.healthz().await.expect_err("should time out");
|
||||
assert!(matches!(err, Error::Timeout(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builder_rejects_missing_base_url() {
|
||||
let err = Client::builder().build().expect_err("should fail");
|
||||
assert!(matches!(err, Error::Config(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builder_rejects_bad_scheme() {
|
||||
let err = Client::builder()
|
||||
.base_url("ftp://nope")
|
||||
.build()
|
||||
.expect_err("should fail");
|
||||
assert!(matches!(err, Error::Config(_)));
|
||||
}
|
||||
|
||||
// ---- audit-driven regression tests --------------------------------------
|
||||
|
||||
/// H1: 4xx body with multibyte char straddling the truncation cutoff must
|
||||
/// not panic. Build a 503-byte string where `ü` (2 bytes UTF-8) lands at
|
||||
/// offset 499..501, so byte 500 is mid-codepoint.
|
||||
#[tokio::test]
|
||||
async fn truncate_handles_multibyte_boundary() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let mut body = String::new();
|
||||
for _ in 0..499 {
|
||||
body.push('a');
|
||||
}
|
||||
body.push('ü'); // bytes 499 and 500
|
||||
for _ in 0..2 {
|
||||
body.push('b');
|
||||
}
|
||||
assert_eq!(body.len(), 503);
|
||||
assert!(!body.is_char_boundary(500));
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/run"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string(body.clone()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let err = c
|
||||
.run(RunRequest {
|
||||
prompt: "x".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
// Just having reached this line — without panicking — is the assertion.
|
||||
assert!(matches!(err, Error::Auth(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
/// H2: `Debug` on `Client` must not leak app or admin tokens.
|
||||
#[tokio::test]
|
||||
async fn client_debug_redacts_bearer() {
|
||||
let server = MockServer::start().await;
|
||||
let c = Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token("cf_super_secret_app_bearer")
|
||||
.admin_token("admin_super_secret_bearer")
|
||||
.build()
|
||||
.unwrap();
|
||||
let dbg = format!("{c:?}");
|
||||
assert!(
|
||||
!dbg.contains("cf_super_secret_app_bearer"),
|
||||
"app token leaked: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
!dbg.contains("admin_super_secret_bearer"),
|
||||
"admin token leaked: {dbg}"
|
||||
);
|
||||
assert!(dbg.contains("<redacted>"), "no redaction marker: {dbg}");
|
||||
|
||||
// ClientBuilder Debug also redacts.
|
||||
let builder = Client::builder()
|
||||
.base_url("http://x")
|
||||
.token("cf_builder_secret");
|
||||
let bdbg = format!("{builder:?}");
|
||||
assert!(
|
||||
!bdbg.contains("cf_builder_secret"),
|
||||
"builder token leaked: {bdbg}"
|
||||
);
|
||||
assert!(bdbg.contains("<redacted>"), "no redaction marker: {bdbg}");
|
||||
}
|
||||
|
||||
/// H2: `Debug` on `AppToken` must not leak the plaintext `token` field.
|
||||
#[test]
|
||||
fn app_token_debug_redacts_token() {
|
||||
let t = clawdforge::AppToken {
|
||||
name: "cauldron".into(),
|
||||
token: "cf_should_not_appear".into(),
|
||||
ip_cidrs: vec!["172.24.0.0/16".into()],
|
||||
};
|
||||
let dbg = format!("{t:?}");
|
||||
assert!(!dbg.contains("cf_should_not_appear"), "leaked: {dbg}");
|
||||
assert!(dbg.contains("<redacted>"), "no marker: {dbg}");
|
||||
// name + ip_cidrs are non-secret and should still print.
|
||||
assert!(dbg.contains("cauldron"));
|
||||
assert!(dbg.contains("172.24.0.0/16"));
|
||||
}
|
||||
|
||||
/// H3: `revoke_token` must reject path-traversal sequences before issuing
|
||||
/// any HTTP request.
|
||||
#[tokio::test]
|
||||
async fn revoke_token_rejects_path_traversal() {
|
||||
let server = MockServer::start().await;
|
||||
// No mock — if a request escaped client-side validation, wiremock would
|
||||
// 404 and we'd see Error::Api, not Error::Config.
|
||||
let c = make_client(&server);
|
||||
for bad in [
|
||||
"../foo", "..", "foo/bar", "foo?x=1", "foo#frag", "", "a/../b",
|
||||
] {
|
||||
let err = c
|
||||
.revoke_token(bad)
|
||||
.await
|
||||
.expect_err(&format!("revoke_token({bad:?}) should reject"));
|
||||
assert!(
|
||||
matches!(err, Error::Config(_)),
|
||||
"{bad:?} produced wrong variant: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// M2: a 204 No Content response from `revoke_token` must Ok-out.
|
||||
#[tokio::test]
|
||||
async fn revoke_token_accepts_204_no_content() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path_regex(r"^/admin/tokens/.+"))
|
||||
.and(header("authorization", "Bearer admin_test_token"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
c.revoke_token("cauldron")
|
||||
.await
|
||||
.expect("204 No Content should be Ok");
|
||||
}
|
||||
|
||||
/// M4: `upload_file` with a `max_upload_bytes` cap rejects oversized files
|
||||
/// before any network I/O.
|
||||
#[tokio::test]
|
||||
async fn upload_file_respects_max_upload_bytes() {
|
||||
let server = MockServer::start().await;
|
||||
// No /files mock — if the cap fails to short-circuit, the test will see
|
||||
// a 404 from wiremock instead of Error::Config.
|
||||
|
||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
// Write 1024 bytes; cap at 512.
|
||||
write!(tmp, "{}", "x".repeat(1024)).unwrap();
|
||||
tmp.flush().unwrap();
|
||||
|
||||
let c = Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token("cf_test_token")
|
||||
.max_upload_bytes(512)
|
||||
.build()
|
||||
.unwrap();
|
||||
let err = c
|
||||
.upload_file(tmp.path(), Some(1800))
|
||||
.await
|
||||
.expect_err("should reject oversize");
|
||||
assert!(matches!(err, Error::Config(_)), "got {err:?}");
|
||||
}
|
||||
425
vendor/clawdforge/tests/sessions.rs
vendored
Normal file
425
vendor/clawdforge/tests/sessions.rs
vendored
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
//! Integration tests for the v0.2 multi-turn Session API.
|
||||
//!
|
||||
//! All tests run against an in-process `wiremock` server — no live clawdforge
|
||||
//! required.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use clawdforge::{Client, Error, SessionOptions, TurnEvent, TurnResult};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_json, header, method, path, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_client(server: &MockServer) -> Client {
|
||||
Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token("cf_test_token")
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("client builds")
|
||||
}
|
||||
|
||||
fn mock_create(session_id: &str) -> Mock {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/sessions"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"session_id": session_id,
|
||||
"agent": "claude",
|
||||
"created_at": 1_700_000_000_i64,
|
||||
})))
|
||||
}
|
||||
|
||||
fn mock_delete_ok(session_id: &str) -> Mock {
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path(format!("/sessions/{session_id}")))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_session_and_close() {
|
||||
let server = MockServer::start().await;
|
||||
mock_create("sess_abc").expect(1).mount(&server).await;
|
||||
mock_delete_ok("sess_abc").expect(1).mount(&server).await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let s = c
|
||||
.new_session(SessionOptions::default())
|
||||
.await
|
||||
.expect("new_session");
|
||||
assert_eq!(s.id(), "sess_abc");
|
||||
assert_eq!(s.agent(), "claude");
|
||||
assert_eq!(s.created_at(), 1_700_000_000);
|
||||
assert!(!s.is_closed());
|
||||
|
||||
s.close().await.expect("close");
|
||||
// wiremock verifies expectations on Drop of the server.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_turn_round_trip() {
|
||||
let server = MockServer::start().await;
|
||||
mock_create("sess_t1").mount(&server).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/sessions/sess_t1/turn"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.and(body_json(json!({"prompt": "hello"})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"session_id": "sess_t1",
|
||||
"turn_index": 1,
|
||||
"events": [
|
||||
{"type": "thinking", "content": "..."},
|
||||
{"type": "text", "content": "hi back"}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"duration_ms": 250
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
// Allow drop-close to land without failing other assertions.
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/sessions/sess_t1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let mut s = c.new_session(SessionOptions::default()).await.unwrap();
|
||||
let r: TurnResult = s.turn("hello").await.unwrap();
|
||||
assert!(r.ok);
|
||||
assert_eq!(r.session_id, "sess_t1");
|
||||
assert_eq!(r.turn_index, 1);
|
||||
assert_eq!(r.events.len(), 2);
|
||||
assert_eq!(r.stop_reason, "end_turn");
|
||||
assert_eq!(r.duration_ms, 250);
|
||||
assert_eq!(r.text(), "hi back");
|
||||
|
||||
// Drive the turn_with_files path too.
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/sessions/sess_t1/turn"))
|
||||
.and(body_json(
|
||||
json!({"prompt": "next", "files": ["ff_one", "ff_two"]}),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"ok": true,
|
||||
"session_id": "sess_t1",
|
||||
"turn_index": 2,
|
||||
"events": [{"type": "text", "content": "ok"}],
|
||||
"stop_reason": "end_turn",
|
||||
"duration_ms": 10
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let r2 = s
|
||||
.turn_with_files("next", &["ff_one".into(), "ff_two".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r2.turn_index, 2);
|
||||
assert_eq!(r2.text(), "ok");
|
||||
|
||||
s.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_close_idempotent_short_circuits() {
|
||||
let server = MockServer::start().await;
|
||||
mock_create("sess_idem").mount(&server).await;
|
||||
// Expect EXACTLY ONE delete — second close() is in-memory.
|
||||
mock_delete_ok("sess_idem").expect(1).mount(&server).await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let s = c.new_session(SessionOptions::default()).await.unwrap();
|
||||
let id = s.id().to_string();
|
||||
s.close().await.unwrap();
|
||||
|
||||
// Second close-equivalent: rebuild a Session-shape via reconstructing the
|
||||
// closed state would require private constructors. Instead, drive the
|
||||
// semantic check via close_session_internal contract: a fresh Session from
|
||||
// a *new* create that we close twice. But since `close` consumes self,
|
||||
// "second close" semantically means a Drop after an explicit close — and
|
||||
// that path is covered by `test_drop_after_explicit_close_no_double_call`.
|
||||
//
|
||||
// What this test asserts is the wiremock `expect(1)` on the DELETE: one
|
||||
// close => one DELETE => idempotency at the network layer holds.
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_fires_async_close() {
|
||||
let server = MockServer::start().await;
|
||||
mock_create("sess_drop").mount(&server).await;
|
||||
mock_delete_ok("sess_drop").expect(1).mount(&server).await;
|
||||
|
||||
let c = make_client(&server);
|
||||
{
|
||||
let _s = c.new_session(SessionOptions::default()).await.unwrap();
|
||||
// _s drops here — Drop spawns the async close.
|
||||
}
|
||||
// Yield repeatedly so the spawned future has a chance to run + the HTTP
|
||||
// request lands at wiremock. wiremock asserts `expect(1)` on Drop of the
|
||||
// server.
|
||||
for _ in 0..50 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drop_after_explicit_close_no_double_call() {
|
||||
let server = MockServer::start().await;
|
||||
mock_create("sess_once").mount(&server).await;
|
||||
// Exactly ONE delete — explicit close fires it, Drop should short-circuit.
|
||||
mock_delete_ok("sess_once").expect(1).mount(&server).await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let s = c.new_session(SessionOptions::default()).await.unwrap();
|
||||
s.close().await.unwrap();
|
||||
// Yield to give any erroneous spawn a chance to land — if Drop spawned a
|
||||
// second DELETE, wiremock's expect(1) would fail.
|
||||
for _ in 0..20 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_sessions() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/sessions"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "sess_a",
|
||||
"agent": "claude",
|
||||
"app_name": "cauldron",
|
||||
"created_at": 1_700_000_000_i64,
|
||||
"last_turn_at": 1_700_000_500_i64,
|
||||
"turn_count": 3,
|
||||
"closed_at": null
|
||||
},
|
||||
{
|
||||
"session_id": "sess_b",
|
||||
"agent": "claude",
|
||||
"app_name": "cauldron",
|
||||
"created_at": 1_700_000_100_i64,
|
||||
"last_turn_at": null,
|
||||
"turn_count": 0,
|
||||
"closed_at": 1_700_001_000_i64
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let list = c.list_sessions().await.unwrap();
|
||||
assert_eq!(list.sessions.len(), 2);
|
||||
assert_eq!(list.sessions[0].session_id, "sess_a");
|
||||
assert_eq!(list.sessions[0].turn_count, 3);
|
||||
assert_eq!(list.sessions[0].last_turn_at, Some(1_700_000_500));
|
||||
assert_eq!(list.sessions[1].closed_at, Some(1_700_001_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_session() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/sessions/sess_q"))
|
||||
.and(header("authorization", "Bearer cf_test_token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"session_id": "sess_q",
|
||||
"agent": "claude",
|
||||
"app_name": "cauldron",
|
||||
"created_at": 1_700_000_000_i64,
|
||||
"last_turn_at": 1_700_000_900_i64,
|
||||
"turn_count": 7,
|
||||
"closed_at": null
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let st = c.get_session("sess_q").await.unwrap();
|
||||
assert_eq!(st.session_id, "sess_q");
|
||||
assert_eq!(st.agent, "claude");
|
||||
assert_eq!(st.app_name, "cauldron");
|
||||
assert_eq!(st.turn_count, 7);
|
||||
assert!(st.closed_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_token_404() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"^/sessions/sess_other"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
|
||||
"detail": "session not found"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let err = c
|
||||
.get_session("sess_other")
|
||||
.await
|
||||
.expect_err("cross-token must 404");
|
||||
match err {
|
||||
Error::Api { status, body } => {
|
||||
assert_eq!(status, 404);
|
||||
assert!(body.contains("session not found"), "body was {body}");
|
||||
}
|
||||
other => panic!("expected Error::Api {{ status: 404, .. }}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_turn_result_text_concat() {
|
||||
let r = TurnResult {
|
||||
ok: true,
|
||||
session_id: "sess".into(),
|
||||
turn_index: 1,
|
||||
events: vec![
|
||||
TurnEvent {
|
||||
event_type: "thinking".into(),
|
||||
content: Some("ignored".into()),
|
||||
name: None,
|
||||
args: None,
|
||||
result: None,
|
||||
},
|
||||
TurnEvent {
|
||||
event_type: "text".into(),
|
||||
content: Some("hello ".into()),
|
||||
name: None,
|
||||
args: None,
|
||||
result: None,
|
||||
},
|
||||
TurnEvent {
|
||||
event_type: "tool_call".into(),
|
||||
content: None,
|
||||
name: Some("Read".into()),
|
||||
args: Some(json!({"path": "/x"})),
|
||||
result: Some(json!({"ok": true})),
|
||||
},
|
||||
TurnEvent {
|
||||
event_type: "text".into(),
|
||||
content: Some("world".into()),
|
||||
name: None,
|
||||
args: None,
|
||||
result: None,
|
||||
},
|
||||
TurnEvent {
|
||||
// text event with None content — must contribute the empty
|
||||
// string, not panic, not stringify "None".
|
||||
event_type: "text".into(),
|
||||
content: None,
|
||||
name: None,
|
||||
args: None,
|
||||
result: None,
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn".into(),
|
||||
duration_ms: 99,
|
||||
};
|
||||
assert_eq!(r.text(), "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_debug_does_not_leak_token() {
|
||||
let server = MockServer::start().await;
|
||||
let secret = "cf_super_secret_session_bearer";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/sessions"))
|
||||
.and(header("authorization", format!("Bearer {secret}").as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"session_id": "sess_dbg",
|
||||
"agent": "claude",
|
||||
"created_at": 1_700_000_000_i64,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/sessions/sess_dbg"))
|
||||
.and(header("authorization", format!("Bearer {secret}").as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = Client::builder()
|
||||
.base_url(server.uri())
|
||||
.token(secret)
|
||||
.build()
|
||||
.unwrap();
|
||||
let s = c.new_session(SessionOptions::default()).await.unwrap();
|
||||
let dbg = format!("{s:?}");
|
||||
assert!(
|
||||
!dbg.contains(secret),
|
||||
"token leaked through Session Debug: {dbg}"
|
||||
);
|
||||
// The Session Debug should print these visible bits.
|
||||
assert!(dbg.contains("sess_dbg"), "session_id missing: {dbg}");
|
||||
assert!(dbg.contains("agent"), "agent field missing: {dbg}");
|
||||
assert!(dbg.contains("closed"), "closed field missing: {dbg}");
|
||||
s.close().await.unwrap();
|
||||
}
|
||||
|
||||
/// Regression: `new_session` with options serializes the `agent` and `meta`
|
||||
/// fields when set, omits them when None.
|
||||
#[tokio::test]
|
||||
async fn test_new_session_options_serialize() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/sessions"))
|
||||
.and(body_json(json!({
|
||||
"agent": "claude",
|
||||
"meta": {"trace_id": "t-123"}
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"session_id": "sess_opts",
|
||||
"agent": "claude",
|
||||
"created_at": 1
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/sessions/sess_opts"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = make_client(&server);
|
||||
let s = c
|
||||
.new_session(SessionOptions {
|
||||
agent: Some("claude".into()),
|
||||
meta: Some(json!({"trace_id": "t-123"})),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(s.id(), "sess_opts");
|
||||
s.close().await.unwrap();
|
||||
}
|
||||
|
||||
/// `get_session` / `close` / `turn` must reject path-traversal session ids
|
||||
/// before issuing any HTTP request — same defense-in-depth pattern as
|
||||
/// `revoke_token`.
|
||||
#[tokio::test]
|
||||
async fn test_session_id_rejects_path_traversal() {
|
||||
let server = MockServer::start().await;
|
||||
let c = make_client(&server);
|
||||
for bad in ["", "../foo", "..", "foo/bar", "foo?x=1", "foo#frag"] {
|
||||
let err = c
|
||||
.get_session(bad)
|
||||
.await
|
||||
.expect_err(&format!("get_session({bad:?}) should reject"));
|
||||
assert!(
|
||||
matches!(err, Error::Config(_)),
|
||||
"{bad:?} produced wrong variant: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue