Workspace migration: split into modular crates, parity CI, release updates
- Convert root to Cargo workspace with crates/ layout - Add deepseek-* crates mirroring Codex architecture - Add parity CI workflow with snapshot/protocol/state tests - Update release workflow to build both deepseek and deepseek-tui binaries - Bump version to 0.3.28
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
name: parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Unit and parity tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
|
||||
- name: Protocol schema sanity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
|
||||
- name: State persistence sanity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
@@ -4,11 +4,42 @@ on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- name: Workspace tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
- name: Protocol schema parity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
|
||||
build:
|
||||
needs: parity
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# --- deepseek (cli) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek
|
||||
@@ -25,13 +56,30 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek.exe
|
||||
artifact_name: deepseek-windows-x64.exe
|
||||
# --- deepseek-tui (TUI) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-linux-x64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek-tui.exe
|
||||
artifact_name: deepseek-tui-windows-x64.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- run: cargo build --release --target ${{ matrix.target }}
|
||||
- run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
- name: Rename binary
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
Generated
+554
-6
@@ -242,6 +242,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
@@ -404,9 +426,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -427,9 +457,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.42"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -503,6 +533,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmp_any"
|
||||
version = "0.8.1"
|
||||
@@ -524,6 +563,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
@@ -570,6 +619,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -745,9 +804,154 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"deepseek-agent",
|
||||
"deepseek-config",
|
||||
"deepseek-core",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-hooks",
|
||||
"deepseek-mcp",
|
||||
"deepseek-protocol",
|
||||
"deepseek-state",
|
||||
"deepseek-tools",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-cli"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"deepseek-agent",
|
||||
"deepseek-app-server",
|
||||
"deepseek-config",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-mcp",
|
||||
"deepseek-state",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"deepseek-agent",
|
||||
"deepseek-config",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-hooks",
|
||||
"deepseek-mcp",
|
||||
"deepseek-protocol",
|
||||
"deepseek-state",
|
||||
"deepseek-tools",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"deepseek-protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.27"
|
||||
version = "0.3.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -799,6 +1003,10 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.3.28"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -942,6 +1150,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dupe"
|
||||
version = "0.9.1"
|
||||
@@ -1038,6 +1252,18 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1153,6 +1379,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -1278,8 +1510,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1289,9 +1523,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1364,6 +1600,15 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -1797,6 +2042,38 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.83"
|
||||
@@ -1860,6 +2137,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -1943,6 +2231,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.94.1"
|
||||
@@ -2065,10 +2359,10 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2297,6 +2591,12 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
@@ -2469,6 +2769,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2509,6 +2818,62 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases 0.2.1",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"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 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
@@ -2534,6 +2899,35 @@ dependencies = [
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[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 = "rangemap"
|
||||
version = "1.7.1"
|
||||
@@ -2672,12 +3066,16 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2703,6 +3101,26 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
@@ -2735,6 +3153,7 @@ version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -2742,21 +3161,62 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -2891,7 +3351,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3960,6 +4433,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4146,6 +4628,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -4182,6 +4673,21 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -4215,6 +4721,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4227,6 +4739,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4239,6 +4757,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4263,6 +4787,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4275,6 +4805,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4287,6 +4823,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4299,6 +4841,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
+30
-60
@@ -1,73 +1,43 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.27"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/agent",
|
||||
"crates/app-server",
|
||||
"crates/cli",
|
||||
"crates/config",
|
||||
"crates/core",
|
||||
"crates/execpolicy",
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/tui-core",
|
||||
]
|
||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.28"
|
||||
edition = "2024"
|
||||
description = "Terminal-native TUI and CLI for DeepSeek models"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
keywords = ["deepseek", "cli", "ai", "agent", "llm"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
toml = "0.9.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
|
||||
@@ -21,7 +21,7 @@ Three modes:
|
||||
- **Agent** — multi-step autonomous tool use
|
||||
- **YOLO** — full auto-approve, no guardrails (preloads tools by default)
|
||||
|
||||
**Recent highlights**: sub‑agent orchestration (background workers, parallel tool calls, dependency‑aware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/sub‑agent state, and model context‑window suffix hints (`-32k`, `-256k`).
|
||||
**Recent highlights**: workspace architecture (modular crates mirroring [Codex](https://github.com/openai/codex) layout), sub-agent orchestration (background workers, parallel tool calls, dependency-aware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/sub-agent state, and model context-window suffix hints (`-32k`, `-256k`).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -31,7 +31,9 @@ cargo install deepseek-tui --locked
|
||||
|
||||
# Or from source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI && cargo install --path . --locked
|
||||
cd DeepSeek-TUI
|
||||
cargo install --path crates/tui --locked # TUI (interactive terminal)
|
||||
cargo install --path crates/cli --locked # CLI (dispatcher + server)
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -45,7 +47,9 @@ api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
deepseek-tui # interactive TUI
|
||||
# or
|
||||
deepseek # CLI dispatcher (delegates to deepseek-tui for interactive use)
|
||||
```
|
||||
|
||||
**Tab** switches modes, **F1** opens help, **Esc** cancels a running request.
|
||||
@@ -53,21 +57,40 @@ deepseek
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek # interactive TUI
|
||||
deepseek -p "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek --yolo # agent mode, all tools auto-approved
|
||||
deepseek doctor # check your setup
|
||||
deepseek models # list available models
|
||||
deepseek serve --http # start HTTP/SSE API server
|
||||
deepseek-tui # interactive TUI
|
||||
deepseek-tui -p "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek-tui --yolo # agent mode, all tools auto-approved
|
||||
deepseek doctor # check your setup
|
||||
deepseek models # list available models
|
||||
deepseek serve --http # start HTTP/SSE API server
|
||||
```
|
||||
|
||||
Within the TUI, use `/config`, `/models`, `/task`, and `Ctrl+K` command palette.
|
||||
|
||||
## Workspace Architecture
|
||||
|
||||
```
|
||||
crates/
|
||||
cli/ deepseek-cli → deepseek CLI dispatcher + server
|
||||
tui/ deepseek-tui → deepseek-tui Interactive terminal UI
|
||||
app-server/ deepseek-app-server HTTP/SSE + JSON-RPC server
|
||||
core/ deepseek-core Agent loop + engine
|
||||
protocol/ deepseek-protocol Request/response framing
|
||||
config/ deepseek-config Configuration + profiles
|
||||
state/ deepseek-state SQLite session persistence
|
||||
tools/ deepseek-tools Tool registry + specs
|
||||
mcp/ deepseek-mcp MCP server integration
|
||||
hooks/ deepseek-hooks Lifecycle hooks
|
||||
execpolicy/ deepseek-execpolicy Approval policy engine
|
||||
agent/ deepseek-agent Model/provider registry
|
||||
tui-core/ deepseek-tui-core TUI state machine scaffold
|
||||
```
|
||||
|
||||
## Model IDs
|
||||
|
||||
Common model IDs: `deepseek-chat`, `deepseek-reasoner`.
|
||||
|
||||
Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include context‑window suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint:
|
||||
Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include context-window suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint:
|
||||
|
||||
```bash
|
||||
deepseek models
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deepseek_config::ProviderKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: ProviderKind,
|
||||
pub aliases: Vec<String>,
|
||||
pub supports_tools: bool,
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelResolution {
|
||||
pub requested: Option<String>,
|
||||
pub resolved: ModelInfo,
|
||||
pub used_fallback: bool,
|
||||
pub fallback_chain: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelRegistry {
|
||||
models: Vec<ModelInfo>,
|
||||
alias_map: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for ModelRegistry {
|
||||
fn default() -> Self {
|
||||
let models = vec![
|
||||
ModelInfo {
|
||||
id: "deepseek-reasoner".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec!["deepseek-r1".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-chat".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec!["deepseek-v3".to_string(), "deepseek-v3.2".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1-mini".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt-4o-mini".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
#[must_use]
|
||||
pub fn new(models: Vec<ModelInfo>) -> Self {
|
||||
let mut alias_map = HashMap::new();
|
||||
for (idx, model) in models.iter().enumerate() {
|
||||
alias_map.insert(normalize(&model.id), idx);
|
||||
for alias in &model.aliases {
|
||||
alias_map.insert(normalize(alias), idx);
|
||||
}
|
||||
}
|
||||
Self { models, alias_map }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ModelInfo> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
&self,
|
||||
requested: Option<&str>,
|
||||
provider_hint: Option<ProviderKind>,
|
||||
) -> ModelResolution {
|
||||
let mut fallback_chain = Vec::new();
|
||||
|
||||
if let Some(name) = requested {
|
||||
fallback_chain.push(format!("requested:{name}"));
|
||||
if let Some(idx) = self.alias_map.get(&normalize(name)) {
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: self.models[*idx].clone(),
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
|
||||
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
|
||||
if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
|
||||
return ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: model,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
|
||||
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
|
||||
id: "deepseek-reasoner".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: Vec::new(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
});
|
||||
fallback_chain.push("global_default:deepseek-reasoner".to_string());
|
||||
ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: final_fallback,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "deepseek-app-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server transport for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent" }
|
||||
deepseek-config = { path = "../config" }
|
||||
deepseek-core = { path = "../core" }
|
||||
deepseek-execpolicy = { path = "../execpolicy" }
|
||||
deepseek-hooks = { path = "../hooks" }
|
||||
deepseek-mcp = { path = "../mcp" }
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
deepseek-state = { path = "../state" }
|
||||
deepseek-tools = { path = "../tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,783 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use deepseek_agent::ModelRegistry;
|
||||
use deepseek_config::{CliRuntimeOverrides, ConfigStore};
|
||||
use deepseek_core::Runtime;
|
||||
use deepseek_execpolicy::ExecPolicyEngine;
|
||||
use deepseek_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
|
||||
use deepseek_mcp::McpManager;
|
||||
use deepseek_protocol::{
|
||||
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse,
|
||||
};
|
||||
use deepseek_state::StateStore;
|
||||
use deepseek_tools::{ToolCall, ToolRegistry};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppServerOptions {
|
||||
pub listen: SocketAddr,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config_path: Option<PathBuf>,
|
||||
config: Arc<RwLock<deepseek_config::ConfigToml>>,
|
||||
runtime: Arc<Mutex<Runtime>>,
|
||||
registry: ModelRegistry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ToolCallRequest {
|
||||
call: ToolCall,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StdioDispatchResult {
|
||||
result: Value,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigGetParams {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigSetParams {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadIdParams {
|
||||
thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadMessageParams {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
}
|
||||
|
||||
pub async fn run(options: AppServerOptions) -> Result<()> {
|
||||
let state = build_state(options.config_path.clone())?;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/thread", post(thread_handler))
|
||||
.route("/app", post(app_handler))
|
||||
.route("/prompt", post(prompt_handler))
|
||||
.route("/tool", post(tool_handler))
|
||||
.route("/jobs", get(jobs_handler))
|
||||
.route("/mcp/startup", post(mcp_startup_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(options.listen).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
let state = build_state(config_path)?;
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin).lines();
|
||||
let mut writer = tokio::io::BufWriter::new(stdout);
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let response = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
|
||||
Ok(dispatch) => {
|
||||
let encoded = jsonrpc_result(request.id, dispatch.result);
|
||||
writer.write_all(encoded.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
if dispatch.should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"protocol": "v2",
|
||||
"service": "deepseek-app-server"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn thread_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ThreadRequest>,
|
||||
) -> Json<ThreadResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
match runtime.handle_thread(req).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(ThreadResponse {
|
||||
thread_id: "error".to_string(),
|
||||
status: format!("error:{err}"),
|
||||
thread: None,
|
||||
threads: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox: None,
|
||||
events: Vec::new(),
|
||||
data: json!({}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PromptRequest>,
|
||||
) -> Json<PromptResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let overrides = CliRuntimeOverrides::default();
|
||||
match runtime.handle_prompt(req, &overrides).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(PromptResponse {
|
||||
output: err.to_string(),
|
||||
model: "unknown".to_string(),
|
||||
events: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ToolCallRequest>,
|
||||
) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let cwd = req
|
||||
.cwd
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
match runtime
|
||||
.invoke_tool(
|
||||
req.call,
|
||||
deepseek_execpolicy::AskForApproval::OnRequest,
|
||||
&cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => Json(value),
|
||||
Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
|
||||
}
|
||||
}
|
||||
|
||||
async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
Json(runtime.app_status())
|
||||
}
|
||||
|
||||
async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let summary = runtime.mcp_startup().await;
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"summary": summary
|
||||
}))
|
||||
}
|
||||
|
||||
async fn app_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AppRequest>,
|
||||
) -> Json<AppResponse> {
|
||||
Json(process_app_request(&state, req).await)
|
||||
}
|
||||
|
||||
fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
|
||||
let store = ConfigStore::load(config_path.clone())?;
|
||||
let config = store.config.clone();
|
||||
let registry = ModelRegistry::default();
|
||||
|
||||
let state_db_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
|
||||
let state_store = StateStore::open(state_db_path)?;
|
||||
|
||||
let mut hooks = HookDispatcher::default();
|
||||
hooks.add_sink(Arc::new(StdoutHookSink));
|
||||
let hook_log_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
|
||||
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
|
||||
|
||||
let runtime = Runtime::new(
|
||||
config.clone(),
|
||||
registry.clone(),
|
||||
state_store,
|
||||
Arc::new(ToolRegistry::default()),
|
||||
Arc::new(McpManager::default()),
|
||||
ExecPolicyEngine::new(Vec::new(), Vec::new()),
|
||||
hooks,
|
||||
);
|
||||
|
||||
Ok(AppState {
|
||||
config_path,
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
runtime: Arc::new(Mutex::new(runtime)),
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thread_request(
|
||||
state: &AppState,
|
||||
req: ThreadRequest,
|
||||
) -> std::result::Result<ThreadResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_thread(req)
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_prompt_request(
|
||||
state: &AppState,
|
||||
req: PromptRequest,
|
||||
) -> std::result::Result<PromptResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_prompt(req, &CliRuntimeOverrides::default())
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn dispatch_stdio_request(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
|
||||
let outcome = match method {
|
||||
"healthz" | "app/healthz" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-app-server",
|
||||
"transport": "stdio"
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"transport": "stdio",
|
||||
"families": ["thread/*", "app/*", "prompt/*"],
|
||||
"methods": [
|
||||
"healthz",
|
||||
"thread/capabilities",
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message",
|
||||
"app/capabilities",
|
||||
"app/request",
|
||||
"app/config/get",
|
||||
"app/config/set",
|
||||
"app/config/unset",
|
||||
"app/config/list",
|
||||
"app/models",
|
||||
"app/thread_loaded_list",
|
||||
"prompt/capabilities",
|
||||
"prompt/request",
|
||||
"prompt/run",
|
||||
"shutdown"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": [
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/request" => {
|
||||
let request: ThreadRequest = parse_params(params)?;
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/create" => {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateParams {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
}
|
||||
let parsed: CreateParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Create {
|
||||
metadata: parsed.metadata,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/start" => {
|
||||
let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/resume" => {
|
||||
let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/fork" => {
|
||||
let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/list" => {
|
||||
let request = ThreadRequest::List(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/read" => {
|
||||
let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/set_name" | "thread/set-name" => {
|
||||
let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/archive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Archive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/unarchive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Unarchive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/message" => {
|
||||
let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Message {
|
||||
thread_id: parsed.thread_id,
|
||||
input: parsed.input,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/capabilities" => {
|
||||
let response = process_app_request(state, AppRequest::Capabilities).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/request" => {
|
||||
let request: AppRequest = parse_params(params)?;
|
||||
let response = process_app_request(state, request).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/get" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/set" => {
|
||||
let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
|
||||
let response = process_app_request(
|
||||
state,
|
||||
AppRequest::ConfigSet {
|
||||
key: parsed.key,
|
||||
value: parsed.value,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/unset" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/list" => {
|
||||
let response = process_app_request(state, AppRequest::ConfigList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/models" => {
|
||||
let response = process_app_request(state, AppRequest::Models).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/thread_loaded_list" | "app/thread-loaded-list" => {
|
||||
let response = process_app_request(state, AppRequest::ThreadLoadedList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"prompt/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": ["prompt/request", "prompt/run"]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"prompt/request" | "prompt/run" => {
|
||||
let request: PromptRequest = parse_params(params)?;
|
||||
let response = handle_prompt_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"shutdown" => StdioDispatchResult {
|
||||
result: json!({"ok": true, "status": "stopped"}),
|
||||
should_exit: true,
|
||||
},
|
||||
_ => return Err(JsonRpcError::method_not_found(method)),
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
|
||||
match req {
|
||||
AppRequest::Capabilities => AppResponse {
|
||||
ok: true,
|
||||
data: json!({
|
||||
"routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
|
||||
"config": ["get", "set", "unset", "list"],
|
||||
"events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
|
||||
"transport": "stdio+http",
|
||||
"config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
|
||||
}),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ConfigGet { key } => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "key": key, "value": cfg.get_value(&key) }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigSet { key, value } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.set_value(&key, &value);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "value": value, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigUnset { key } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.unset_value(&key);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigList => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "values": cfg.list_values() }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::Models => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "models": state.registry.list() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ThreadLoadedList => {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let response = runtime
|
||||
.handle_thread(deepseek_protocol::ThreadRequest::List(
|
||||
deepseek_protocol::ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
match response {
|
||||
Ok(thread_resp) => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "threads": thread_resp.threads }),
|
||||
events: thread_resp.events,
|
||||
},
|
||||
Err(err) => AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": err.to_string() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_config(state: &AppState, config: deepseek_config::ConfigToml) -> Result<()> {
|
||||
if state.config_path.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut store = ConfigStore::load(state.config_path.clone())?;
|
||||
store.config = config;
|
||||
store.save()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use deepseek_app_server::{AppServerOptions, run};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "deepseek-app-server",
|
||||
about = "Run the DeepSeek app-server transport"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
#[arg(long, default_value_t = 8787)]
|
||||
port: u16,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let listen: SocketAddr = format!("{}:{}", cli.host, cli.port)
|
||||
.parse()
|
||||
.with_context(|| format!("invalid listen address {}:{}", cli.host, cli.port))?;
|
||||
run(AppServerOptions {
|
||||
listen,
|
||||
config_path: cli.config,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "deepseek-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style CLI facade for DeepSeek workspace architecture"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent" }
|
||||
deepseek-app-server = { path = "../app-server" }
|
||||
deepseek-config = { path = "../config" }
|
||||
deepseek-execpolicy = { path = "../execpolicy" }
|
||||
deepseek-mcp = { path = "../mcp" }
|
||||
deepseek-state = { path = "../state" }
|
||||
chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "deepseek-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Config schema and precedence model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -0,0 +1,477 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CONFIG_FILE_NAME: &str = "config.toml";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-reasoner";
|
||||
const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProviderKind {
|
||||
#[default]
|
||||
Deepseek,
|
||||
Openai,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Deepseek => "deepseek",
|
||||
Self::Openai => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek" | "deep-seek" => Some(Self::Deepseek),
|
||||
"openai" | "open-ai" => Some(Self::Openai),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderConfigToml {
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProvidersToml {
|
||||
#[serde(default)]
|
||||
pub deepseek: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub openai: ProviderConfigToml,
|
||||
}
|
||||
|
||||
impl ProvidersToml {
|
||||
#[must_use]
|
||||
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &self.deepseek,
|
||||
ProviderKind::Openai => &self.openai,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &mut self.deepseek,
|
||||
ProviderKind::Openai => &mut self.openai,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub provider: ProviderKind,
|
||||
pub model: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub chatgpt_access_token: Option<String>,
|
||||
pub device_code_session: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersToml,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
#[must_use]
|
||||
pub fn get_value(&self, key: &str) -> Option<String> {
|
||||
match key {
|
||||
"provider" => Some(self.provider.as_str().to_string()),
|
||||
"model" => self.model.clone(),
|
||||
"auth.mode" => self.auth_mode.clone(),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
|
||||
"auth.device_code_session" => self.device_code_session.clone(),
|
||||
"output_mode" => self.output_mode.clone(),
|
||||
"log_level" => self.log_level.clone(),
|
||||
"telemetry" => self.telemetry.map(|v| v.to_string()),
|
||||
"approval_policy" => self.approval_policy.clone(),
|
||||
"sandbox_mode" => self.sandbox_mode.clone(),
|
||||
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
|
||||
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
|
||||
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
|
||||
"providers.openai.api_key" => self.providers.openai.api_key.clone(),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url.clone(),
|
||||
"providers.openai.model" => self.providers.openai.model.clone(),
|
||||
_ => self.extras.get(key).map(toml::Value::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => {
|
||||
self.provider = ProviderKind::parse(value)
|
||||
.with_context(|| format!("unknown provider '{value}'"))?;
|
||||
}
|
||||
"model" => self.model = Some(value.to_string()),
|
||||
"auth.mode" => self.auth_mode = Some(value.to_string()),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
|
||||
"auth.device_code_session" => self.device_code_session = Some(value.to_string()),
|
||||
"output_mode" => self.output_mode = Some(value.to_string()),
|
||||
"log_level" => self.log_level = Some(value.to_string()),
|
||||
"telemetry" => {
|
||||
self.telemetry = Some(parse_bool(value)?);
|
||||
}
|
||||
"approval_policy" => self.approval_policy = Some(value.to_string()),
|
||||
"sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
|
||||
"providers.deepseek.api_key" => {
|
||||
self.providers.deepseek.api_key = Some(value.to_string())
|
||||
}
|
||||
"providers.deepseek.base_url" => {
|
||||
self.providers.deepseek.base_url = Some(value.to_string());
|
||||
}
|
||||
"providers.deepseek.model" => self.providers.deepseek.model = Some(value.to_string()),
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
|
||||
"providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
|
||||
_ => {
|
||||
self.extras
|
||||
.insert(key.to_string(), toml::Value::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unset_value(&mut self, key: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => self.provider = ProviderKind::Deepseek,
|
||||
"model" => self.model = None,
|
||||
"auth.mode" => self.auth_mode = None,
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = None,
|
||||
"auth.device_code_session" => self.device_code_session = None,
|
||||
"output_mode" => self.output_mode = None,
|
||||
"log_level" => self.log_level = None,
|
||||
"telemetry" => self.telemetry = None,
|
||||
"approval_policy" => self.approval_policy = None,
|
||||
"sandbox_mode" => self.sandbox_mode = None,
|
||||
"providers.deepseek.api_key" => self.providers.deepseek.api_key = None,
|
||||
"providers.deepseek.base_url" => self.providers.deepseek.base_url = None,
|
||||
"providers.deepseek.model" => self.providers.deepseek.model = None,
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = None,
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = None,
|
||||
"providers.openai.model" => self.providers.openai.model = None,
|
||||
_ => {
|
||||
self.extras.remove(key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list_values(&self) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
out.insert("provider".to_string(), self.provider.as_str().to_string());
|
||||
|
||||
if let Some(v) = self.model.as_ref() {
|
||||
out.insert("model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.auth_mode.as_ref() {
|
||||
out.insert("auth.mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.chatgpt_access_token.as_ref() {
|
||||
out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.device_code_session.as_ref() {
|
||||
out.insert("auth.device_code_session".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.output_mode.as_ref() {
|
||||
out.insert("output_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.log_level.as_ref() {
|
||||
out.insert("log_level".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.telemetry {
|
||||
out.insert("telemetry".to_string(), v.to_string());
|
||||
}
|
||||
if let Some(v) = self.approval_policy.as_ref() {
|
||||
out.insert("approval_policy".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.sandbox_mode.as_ref() {
|
||||
out.insert("sandbox_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.api_key.as_ref() {
|
||||
out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.base_url.as_ref() {
|
||||
out.insert("providers.deepseek.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.model.as_ref() {
|
||||
out.insert("providers.deepseek.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.api_key.as_ref() {
|
||||
out.insert("providers.openai.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.openai.base_url.as_ref() {
|
||||
out.insert("providers.openai.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.model.as_ref() {
|
||||
out.insert("providers.openai.model".to_string(), v.clone());
|
||||
}
|
||||
|
||||
for (k, v) in &self.extras {
|
||||
out.insert(k.clone(), v.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
|
||||
let env = EnvRuntimeOverrides::load();
|
||||
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
|
||||
|
||||
let provider_cfg = self.providers.for_provider(provider);
|
||||
let api_key = cli
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| env.api_key_for(provider))
|
||||
.or_else(|| provider_cfg.api_key.clone());
|
||||
|
||||
let base_url = cli
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| env.base_url_for(provider))
|
||||
.or_else(|| provider_cfg.base_url.clone())
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
|
||||
});
|
||||
|
||||
let model = cli
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| env.model.clone())
|
||||
.or_else(|| provider_cfg.model.clone())
|
||||
.or_else(|| self.model.clone())
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
|
||||
});
|
||||
|
||||
let output_mode = cli
|
||||
.output_mode
|
||||
.clone()
|
||||
.or_else(|| env.output_mode.clone())
|
||||
.or_else(|| self.output_mode.clone());
|
||||
let auth_mode = cli
|
||||
.auth_mode
|
||||
.clone()
|
||||
.or_else(|| env.auth_mode.clone())
|
||||
.or_else(|| self.auth_mode.clone());
|
||||
let log_level = cli
|
||||
.log_level
|
||||
.clone()
|
||||
.or_else(|| env.log_level.clone())
|
||||
.or_else(|| self.log_level.clone());
|
||||
let telemetry = cli
|
||||
.telemetry
|
||||
.or(env.telemetry)
|
||||
.or(self.telemetry)
|
||||
.unwrap_or(false);
|
||||
let approval_policy = cli
|
||||
.approval_policy
|
||||
.clone()
|
||||
.or_else(|| env.approval_policy.clone())
|
||||
.or_else(|| self.approval_policy.clone());
|
||||
let sandbox_mode = cli
|
||||
.sandbox_mode
|
||||
.clone()
|
||||
.or_else(|| env.sandbox_mode.clone())
|
||||
.or_else(|| self.sandbox_mode.clone());
|
||||
|
||||
ResolvedRuntimeOptions {
|
||||
provider,
|
||||
model,
|
||||
api_key,
|
||||
base_url,
|
||||
auth_mode,
|
||||
output_mode,
|
||||
log_level,
|
||||
telemetry,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CliRuntimeOverrides {
|
||||
pub provider: Option<ProviderKind>,
|
||||
pub model: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRuntimeOptions {
|
||||
pub provider: ProviderKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: String,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: bool,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
pub config: ConfigToml,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load(path: Option<PathBuf>) -> Result<Self> {
|
||||
let path = resolve_config_path(path)?;
|
||||
if !path.exists() {
|
||||
return Ok(Self {
|
||||
path,
|
||||
config: ConfigToml::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||
let parsed: ConfigToml = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config at {}", path.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
config: parsed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create config directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
|
||||
fs::write(&self.path, body)
|
||||
.with_context(|| format!("failed to write config at {}", self.path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return Ok(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
default_config_path()
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("failed to resolve home directory for config path")?;
|
||||
Ok(home.join(".deepseek").join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Result<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" | "enabled" => Ok(true),
|
||||
"0" | "false" | "no" | "off" | "disabled" => Ok(false),
|
||||
_ => bail!("invalid boolean '{raw}'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_secret(secret: &str) -> String {
|
||||
if secret.len() <= 8 {
|
||||
return "********".to_string();
|
||||
}
|
||||
format!("{}***{}", &secret[..4], &secret[secret.len() - 4..])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct EnvRuntimeOverrides {
|
||||
provider: Option<ProviderKind>,
|
||||
model: Option<String>,
|
||||
output_mode: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
log_level: Option<String>,
|
||||
telemetry: Option<bool>,
|
||||
approval_policy: Option<String>,
|
||||
sandbox_mode: Option<String>,
|
||||
deepseek_api_key: Option<String>,
|
||||
openai_api_key: Option<String>,
|
||||
deepseek_base_url: Option<String>,
|
||||
openai_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvRuntimeOverrides {
|
||||
fn load() -> Self {
|
||||
Self {
|
||||
provider: std::env::var("DEEPSEEK_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|v| ProviderKind::parse(&v)),
|
||||
model: std::env::var("DEEPSEEK_MODEL").ok(),
|
||||
output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
|
||||
auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
|
||||
log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
|
||||
telemetry: std::env::var("DEEPSEEK_TELEMETRY")
|
||||
.ok()
|
||||
.and_then(|v| parse_bool(&v).ok()),
|
||||
approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
|
||||
sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
|
||||
deepseek_api_key: std::env::var("DEEPSEEK_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_api_key: std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_base_url: std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_api_key.clone(),
|
||||
ProviderKind::Openai => self.openai_api_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_base_url.clone(),
|
||||
ProviderKind::Openai => self.openai_base_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "deepseek-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent" }
|
||||
deepseek-config = { path = "../config" }
|
||||
deepseek-execpolicy = { path = "../execpolicy" }
|
||||
deepseek-hooks = { path = "../hooks" }
|
||||
deepseek-mcp = { path = "../mcp" }
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
deepseek-state = { path = "../state" }
|
||||
deepseek-tools = { path = "../tools" }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "deepseek-execpolicy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Execution policy and approval model parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyAmendment {
|
||||
pub prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ExecApprovalRequirement {
|
||||
Skip {
|
||||
bypass_sandbox: bool,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
NeedsApproval {
|
||||
reason: String,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
},
|
||||
Forbidden {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecApprovalRequirement {
|
||||
pub fn reason(&self) -> &str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
|
||||
ExecApprovalRequirement::Forbidden { reason } => reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> &'static str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "allowed",
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
|
||||
ExecApprovalRequirement::Forbidden { .. } => "forbidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyDecision {
|
||||
pub allow: bool,
|
||||
pub requires_approval: bool,
|
||||
pub requirement: ExecApprovalRequirement,
|
||||
pub matched_rule: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyDecision {
|
||||
pub fn reason(&self) -> &str {
|
||||
self.requirement.reason()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecPolicyContext<'a> {
|
||||
pub command: &'a str,
|
||||
pub cwd: &'a str,
|
||||
pub ask_for_approval: AskForApproval,
|
||||
pub sandbox_mode: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExecPolicyEngine {
|
||||
trusted_prefixes: Vec<String>,
|
||||
denied_prefixes: Vec<String>,
|
||||
approved_for_session: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyEngine {
|
||||
pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
|
||||
Self {
|
||||
trusted_prefixes,
|
||||
denied_prefixes,
|
||||
approved_for_session: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remember_session_approval(&mut self, approval_key: String) {
|
||||
self.approved_for_session.insert(approval_key);
|
||||
}
|
||||
|
||||
pub fn is_session_approved(&self, approval_key: &str) -> bool {
|
||||
self.approved_for_session.contains(approval_key)
|
||||
}
|
||||
|
||||
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
|
||||
let normalized = normalize_command(ctx.command);
|
||||
if let Some(rule) = self
|
||||
.denied_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
{
|
||||
return Ok(ExecPolicyDecision {
|
||||
allow: false,
|
||||
requires_approval: false,
|
||||
matched_rule: Some(rule.clone()),
|
||||
requirement: ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("Command blocked by denied prefix rule '{rule}'"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let trusted_rule = self
|
||||
.trusted_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
.cloned();
|
||||
let is_trusted = trusted_rule.is_some();
|
||||
|
||||
let requirement = match ctx.ask_for_approval {
|
||||
AskForApproval::Never => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden {
|
||||
reason: "Policy is configured to reject rule-exceptions.".to_string(),
|
||||
},
|
||||
_ => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: if is_trusted {
|
||||
"Approval requested by policy mode.".to_string()
|
||||
} else {
|
||||
"Unmatched command prefix requires approval.".to_string()
|
||||
},
|
||||
proposed_execpolicy_amendment: if is_trusted {
|
||||
None
|
||||
} else {
|
||||
Some(ExecPolicyAmendment {
|
||||
prefixes: vec![first_token(ctx.command)],
|
||||
})
|
||||
},
|
||||
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
|
||||
host: ctx.cwd.to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let (allow, requires_approval) = match requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => (true, false),
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
|
||||
ExecApprovalRequirement::Forbidden { .. } => (false, false),
|
||||
};
|
||||
|
||||
Ok(ExecPolicyDecision {
|
||||
allow,
|
||||
requires_approval,
|
||||
matched_rule: trusted_rule,
|
||||
requirement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_command(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn first_token(command: &str) -> String {
|
||||
command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "deepseek-hooks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Hook dispatch and notifications parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use deepseek_protocol::EventFrame;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolLifecycle {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
phase: String,
|
||||
payload: Value,
|
||||
},
|
||||
JobLifecycle {
|
||||
job_id: String,
|
||||
phase: String,
|
||||
progress: Option<u8>,
|
||||
detail: Option<String>,
|
||||
},
|
||||
ApprovalLifecycle {
|
||||
approval_id: String,
|
||||
phase: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
GenericEventFrame {
|
||||
frame: EventFrame,
|
||||
},
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait HookSink: Send + Sync {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StdoutHookSink;
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for StdoutHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
println!("{}", event.to_json());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JsonlHookSink {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonlHookSink {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for JsonlHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("failed to create hook log directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
|
||||
let payload = json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event
|
||||
});
|
||||
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
|
||||
file.write_all(encoded.as_bytes())
|
||||
.await
|
||||
.context("failed to write hook event")?;
|
||||
file.write_all(b"\n")
|
||||
.await
|
||||
.context("failed to write hook event newline")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebhookHookSink {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WebhookHookSink {
|
||||
pub fn new(url: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for WebhookHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
let mut retries = 0usize;
|
||||
loop {
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.url)
|
||||
.json(&json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
if retries >= 2 {
|
||||
anyhow::bail!("webhook returned non-success status {}", response.status());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if retries >= 2 {
|
||||
return Err(err).context("webhook request failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
retries += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct HookDispatcher {
|
||||
sinks: Vec<Arc<dyn HookSink>>,
|
||||
}
|
||||
|
||||
impl HookDispatcher {
|
||||
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
|
||||
self.sinks.push(sink);
|
||||
}
|
||||
|
||||
pub async fn emit(&self, event: HookEvent) {
|
||||
for sink in &self.sinks {
|
||||
let _ = sink.emit(&event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "deepseek-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server lifecycle and tool proxy compatibility for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,893 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolFilter {
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerDefinition {
|
||||
pub config: McpServerConfig,
|
||||
#[serde(default)]
|
||||
pub filter: ToolFilter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub server_name: String,
|
||||
pub tool_name: String,
|
||||
pub qualified_name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceDescriptor {
|
||||
pub server_name: String,
|
||||
pub uri: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub trait McpManagedClient: Send + Sync {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||
fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value>;
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>>;
|
||||
fn read_resource(&self, uri: &str) -> Result<Value>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryMcpClient {
|
||||
tools: HashMap<String, Value>,
|
||||
resources: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl InMemoryMcpClient {
|
||||
pub fn with_tool(mut self, name: &str, sample_result: Value) -> Self {
|
||||
self.tools.insert(name.to_string(), sample_result);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_resource(mut self, uri: &str, data: Value) -> Self {
|
||||
self.resources.insert(uri.to_string(), data);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManagedClient for InMemoryMcpClient {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
Ok(self
|
||||
.tools
|
||||
.keys()
|
||||
.map(|name| McpToolDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
tool_name: name.clone(),
|
||||
qualified_name: name.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn call_tool(&self, tool_name: &str, _arguments: Value) -> Result<Value> {
|
||||
self.tools
|
||||
.get(tool_name)
|
||||
.cloned()
|
||||
.with_context(|| format!("tool '{tool_name}' not found"))
|
||||
}
|
||||
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
Ok(self
|
||||
.resources
|
||||
.keys()
|
||||
.map(|uri| McpResourceDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
uri: uri.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_resource(&self, uri: &str) -> Result<Value> {
|
||||
self.resources
|
||||
.get(uri)
|
||||
.cloned()
|
||||
.with_context(|| format!("resource '{uri}' not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct McpManager {
|
||||
configs: HashMap<String, (McpServerConfig, ToolFilter)>,
|
||||
clients: HashMap<String, Box<dyn McpManagedClient>>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn register_server(
|
||||
&mut self,
|
||||
config: McpServerConfig,
|
||||
filter: ToolFilter,
|
||||
client: Box<dyn McpManagedClient>,
|
||||
) {
|
||||
self.clients.insert(config.name.clone(), client);
|
||||
self.configs.insert(config.name.clone(), (config, filter));
|
||||
}
|
||||
|
||||
pub fn start_all<F>(&self, mut emit: F) -> McpStartupCompleteEvent
|
||||
where
|
||||
F: FnMut(McpStartupUpdateEvent),
|
||||
{
|
||||
let mut ready = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut cancelled = Vec::new();
|
||||
for (server_name, (cfg, _)) in &self.configs {
|
||||
if !cfg.enabled {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Cancelled,
|
||||
});
|
||||
cancelled.push(server_name.clone());
|
||||
continue;
|
||||
}
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Starting,
|
||||
});
|
||||
if self.clients.contains_key(server_name) {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Ready,
|
||||
});
|
||||
ready.push(server_name.clone());
|
||||
} else {
|
||||
let error = "client not registered".to_string();
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: error.clone(),
|
||||
},
|
||||
});
|
||||
failed.push(McpStartupFailure {
|
||||
server_name: server_name.clone(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
McpStartupCompleteEvent {
|
||||
ready,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_server(&mut self, server_name: &str) -> Result<()> {
|
||||
self.clients
|
||||
.remove(server_name)
|
||||
.with_context(|| format!("server '{server_name}' is not running"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_server(&mut self, server_name: &str) -> Result<()> {
|
||||
let had_config = self.configs.remove(server_name).is_some();
|
||||
self.clients.remove(server_name);
|
||||
if !had_config {
|
||||
bail!("server '{server_name}' is not registered");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for (server_name, (_, filter)) in &self.configs {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
let tools = client.list_tools()?;
|
||||
for tool in tools {
|
||||
if !allowed_by_filter(&tool.tool_name, filter) {
|
||||
continue;
|
||||
}
|
||||
let qualified_name = qualify_tool_name(server_name, &tool.tool_name);
|
||||
out.push(McpToolDescriptor {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.tool_name,
|
||||
qualified_name,
|
||||
description: tool.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn call_tool(&self, server_name: &str, tool_name: &str, arguments: Value) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.call_tool(tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn call_qualified_tool(
|
||||
&self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value> {
|
||||
let (server_name, tool_name) = parse_qualified_tool_name(qualified_tool_name)
|
||||
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"))?;
|
||||
self.call_tool(&server_name, &tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
for mut resource in client.list_resources()? {
|
||||
resource.server_name = server_name.clone();
|
||||
out.push(resource);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.read_resource(uri)
|
||||
}
|
||||
|
||||
pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result<Vec<Value>> {
|
||||
let mut notices = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
notices.push(json!({
|
||||
"server_name": server_name,
|
||||
"method": "codex/sandbox-state/update",
|
||||
"params": {
|
||||
"sandbox_mode": sandbox_mode,
|
||||
"cwd": cwd
|
||||
}
|
||||
}));
|
||||
}
|
||||
Ok(notices)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn allowed_by_filter(name: &str, filter: &ToolFilter) -> bool {
|
||||
if filter.deny.iter().any(|pattern| pattern == name) {
|
||||
return false;
|
||||
}
|
||||
if filter.allow.is_empty() {
|
||||
return true;
|
||||
}
|
||||
filter.allow.iter().any(|pattern| pattern == name)
|
||||
}
|
||||
|
||||
fn sanitize_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn qualify_tool_name(server: &str, tool: &str) -> String {
|
||||
let mut name = format!(
|
||||
"mcp__{}__{}",
|
||||
sanitize_component(server),
|
||||
sanitize_component(tool)
|
||||
);
|
||||
if name.len() > 64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
name.hash(&mut hasher);
|
||||
let hash = format!("{:x}", hasher.finish());
|
||||
name.truncate(48);
|
||||
name.push('_');
|
||||
name.push_str(&hash[..12]);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn parse_qualified_tool_name(value: &str) -> Result<(String, String)> {
|
||||
let Some(stripped) = value.strip_prefix("mcp__") else {
|
||||
bail!("missing mcp__ prefix");
|
||||
};
|
||||
let mut split = stripped.splitn(2, "__");
|
||||
let server = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing server segment")?;
|
||||
let tool = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing tool segment")?;
|
||||
Ok((server, tool))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsCallParams {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
tool: Option<String>,
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesReadParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerRegisterParams {
|
||||
server: McpServerConfig,
|
||||
#[serde(default)]
|
||||
filter: ToolFilter,
|
||||
#[serde(default = "default_true")]
|
||||
start: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerNameParams {
|
||||
name: String,
|
||||
}
|
||||
|
||||
struct StdioMcpState {
|
||||
manager: McpManager,
|
||||
definitions: HashMap<String, McpServerDefinition>,
|
||||
running: HashMap<String, bool>,
|
||||
lifecycle_state: String,
|
||||
}
|
||||
|
||||
pub fn run_stdio_server(
|
||||
initial_definitions: Vec<McpServerDefinition>,
|
||||
) -> Result<Vec<McpServerDefinition>> {
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
let mut stderr = io::stderr();
|
||||
let mut state = build_stdio_state(initial_definitions);
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line.context("failed to read stdio line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let msg = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writeln!(stdout, "{msg}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&mut state, &request.method, request.params) {
|
||||
Ok((result, should_exit)) => {
|
||||
let payload = jsonrpc_result(request.id, result);
|
||||
writeln!(stdout, "{payload}")?;
|
||||
stdout.flush()?;
|
||||
if should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
state.lifecycle_state = "stopped".to_string();
|
||||
let _ = writeln!(stderr, "deepseek-mcp stdio server exited");
|
||||
let mut definitions: Vec<McpServerDefinition> = state.definitions.into_values().collect();
|
||||
definitions.sort_by(|a, b| a.config.name.cmp(&b.config.name));
|
||||
Ok(definitions)
|
||||
}
|
||||
|
||||
fn build_stdio_state(initial_definitions: Vec<McpServerDefinition>) -> StdioMcpState {
|
||||
let mut manager = McpManager::default();
|
||||
let mut definitions = HashMap::new();
|
||||
let mut running = HashMap::new();
|
||||
|
||||
for definition in initial_definitions {
|
||||
let name = definition.config.name.clone();
|
||||
let should_start = definition.config.enabled;
|
||||
definitions.insert(name.clone(), definition.clone());
|
||||
if should_start {
|
||||
manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
running.insert(name, true);
|
||||
} else {
|
||||
running.insert(name, false);
|
||||
}
|
||||
}
|
||||
|
||||
StdioMcpState {
|
||||
manager,
|
||||
definitions,
|
||||
running,
|
||||
lifecycle_state: "running".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stdio_client(server_name: &str) -> Box<dyn McpManagedClient> {
|
||||
let health_uri = format!("mcp://{server_name}/health");
|
||||
let capabilities_uri = format!("mcp://{server_name}/capabilities");
|
||||
Box::new(
|
||||
InMemoryMcpClient::default()
|
||||
.with_tool(
|
||||
"health",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_tool(
|
||||
"capabilities",
|
||||
json!({
|
||||
"tools": ["health", "capabilities"],
|
||||
"resources": [health_uri.clone(), capabilities_uri.clone()]
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&health_uri,
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&capabilities_uri,
|
||||
json!({
|
||||
"server_name": server_name,
|
||||
"methods": [
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister"
|
||||
]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_rpc_methods() -> Vec<&'static str> {
|
||||
vec![
|
||||
"initialize",
|
||||
"healthz",
|
||||
"capabilities",
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister",
|
||||
"shutdown",
|
||||
]
|
||||
}
|
||||
|
||||
fn lifecycle_snapshot(state: &StdioMcpState) -> Value {
|
||||
let mut servers: Vec<Value> = state
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|(name, definition)| {
|
||||
let is_running = state.running.get(name).copied().unwrap_or(false);
|
||||
json!({
|
||||
"name": name,
|
||||
"enabled": definition.config.enabled,
|
||||
"running": is_running,
|
||||
"command": definition.config.command.clone(),
|
||||
"args": definition.config.args.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
servers.sort_by(|a, b| {
|
||||
let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
a_name.cmp(b_name)
|
||||
});
|
||||
|
||||
let running_count = state.running.values().filter(|running| **running).count();
|
||||
json!({
|
||||
"status": state.lifecycle_state,
|
||||
"servers": servers,
|
||||
"counts": {
|
||||
"defined": state.definitions.len(),
|
||||
"running": running_count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn parse_server_from_uri(uri: &str) -> Option<String> {
|
||||
let stripped = uri.strip_prefix("mcp://")?;
|
||||
let server = stripped.split('/').next()?;
|
||||
if server.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(server.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stdio_request(
|
||||
state: &mut StdioMcpState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<(Value, bool), JsonRpcError> {
|
||||
match method {
|
||||
"initialize" | "capabilities" => Ok((
|
||||
json!({
|
||||
"server": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"methods": default_rpc_methods(),
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"healthz" => Ok((
|
||||
json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"tools/list" => {
|
||||
let parsed: ToolsListParams = parse_params(params_or_object(params))?;
|
||||
let mut tools = state
|
||||
.manager
|
||||
.list_tools()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
tools.retain(|tool| tool.server_name == server);
|
||||
}
|
||||
Ok((json!({ "tools": tools }), false))
|
||||
}
|
||||
"tools/call" => {
|
||||
let parsed: ToolsCallParams = parse_params(params_or_object(params))?;
|
||||
let ToolsCallParams {
|
||||
name,
|
||||
tool,
|
||||
server,
|
||||
arguments,
|
||||
} = parsed;
|
||||
let tool_name = name
|
||||
.or(tool)
|
||||
.context("missing tool name")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let arguments = if arguments.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
arguments
|
||||
};
|
||||
let result = if tool_name.starts_with("mcp__") {
|
||||
state
|
||||
.manager
|
||||
.call_qualified_tool(&tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
} else {
|
||||
let server = server
|
||||
.context("missing server for unqualified tool")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
state
|
||||
.manager
|
||||
.call_tool(&server, &tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
};
|
||||
Ok((json!({ "result": result }), false))
|
||||
}
|
||||
"resources/list" => {
|
||||
let parsed: ResourcesListParams = parse_params(params_or_object(params))?;
|
||||
let mut resources = state
|
||||
.manager
|
||||
.list_resources()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
resources.retain(|resource| resource.server_name == server);
|
||||
}
|
||||
Ok((json!({ "resources": resources }), false))
|
||||
}
|
||||
"resources/read" => {
|
||||
let parsed: ResourcesReadParams = parse_params(params_or_object(params))?;
|
||||
let ResourcesReadParams { server, uri } = parsed;
|
||||
let server_name = server
|
||||
.or_else(|| parse_server_from_uri(&uri))
|
||||
.context("missing server for resource read")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let value = state
|
||||
.manager
|
||||
.read_resource(&server_name, &uri)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
Ok((json!({ "resource": value }), false))
|
||||
}
|
||||
"server/list" | "servers/list" => {
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/register" | "servers/register" => {
|
||||
let parsed: ServerRegisterParams = parse_params(params_or_object(params))?;
|
||||
let name = parsed.server.name.clone();
|
||||
if name.trim().is_empty() {
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"server.name must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
if state.definitions.contains_key(&name) {
|
||||
let _ = state.manager.unregister_server(&name);
|
||||
}
|
||||
state.definitions.insert(
|
||||
name.clone(),
|
||||
McpServerDefinition {
|
||||
config: parsed.server.clone(),
|
||||
filter: parsed.filter.clone(),
|
||||
},
|
||||
);
|
||||
let should_run = parsed.start && parsed.server.enabled;
|
||||
if should_run {
|
||||
state.manager.register_server(
|
||||
parsed.server.clone(),
|
||||
parsed.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
}
|
||||
state.running.insert(name, should_run);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/start" | "servers/start" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
let definition = state
|
||||
.definitions
|
||||
.get(&parsed.name)
|
||||
.cloned()
|
||||
.with_context(|| format!("server '{}' is not defined", parsed.name))
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
if !definition.config.enabled {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is disabled",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
if !state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state.manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&parsed.name),
|
||||
);
|
||||
state.running.insert(parsed.name, true);
|
||||
}
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/stop" | "servers/stop" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state
|
||||
.manager
|
||||
.stop_server(&parsed.name)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
}
|
||||
state.running.insert(parsed.name, false);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/unregister" | "servers/unregister" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.definitions.remove(&parsed.name).is_none() {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is not defined",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
let _ = state.manager.unregister_server(&parsed.name);
|
||||
state.running.remove(&parsed.name);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"shutdown" => {
|
||||
state.lifecycle_state = "shutting_down".to_string();
|
||||
Ok((
|
||||
json!({
|
||||
"ok": true,
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
true,
|
||||
))
|
||||
}
|
||||
_ => Err(JsonRpcError::method_not_found(method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server protocol frames for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope<T> {
|
||||
pub request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadStartParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResumeParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub history: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadForkParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadListParams {
|
||||
#[serde(default)]
|
||||
pub include_archived: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadReadParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ThreadRequest {
|
||||
Create {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
},
|
||||
Start(ThreadStartParams),
|
||||
Resume(ThreadResumeParams),
|
||||
Fork(ThreadForkParams),
|
||||
List(ThreadListParams),
|
||||
Read(ThreadReadParams),
|
||||
SetName(ThreadSetNameParams),
|
||||
Archive {
|
||||
thread_id: String,
|
||||
},
|
||||
Unarchive {
|
||||
thread_id: String,
|
||||
},
|
||||
Message {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub thread_id: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread: Option<Thread>,
|
||||
#[serde(default)]
|
||||
pub threads: Vec<Thread>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AppRequest {
|
||||
Capabilities,
|
||||
ConfigGet { key: String },
|
||||
ConfigSet { key: String, value: String },
|
||||
ConfigUnset { key: String },
|
||||
ConfigList,
|
||||
Models,
|
||||
ThreadLoadedList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppResponse {
|
||||
pub ok: bool,
|
||||
pub data: Value,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptResponse {
|
||||
pub output: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
Function,
|
||||
Mcp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalShellParams {
|
||||
pub command: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolPayload {
|
||||
Function {
|
||||
arguments: String,
|
||||
},
|
||||
Custom {
|
||||
input: String,
|
||||
},
|
||||
LocalShell {
|
||||
params: LocalShellParams,
|
||||
},
|
||||
Mcp {
|
||||
server: String,
|
||||
tool: String,
|
||||
raw_arguments: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_tool_call_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolOutput {
|
||||
Function {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<Value>,
|
||||
success: bool,
|
||||
},
|
||||
Mcp {
|
||||
result: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkPolicyRuleAction {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkPolicyAmendment {
|
||||
pub host: String,
|
||||
pub action: NetworkPolicyRuleAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReviewDecision {
|
||||
Approved,
|
||||
ApprovedExecpolicyAmendment,
|
||||
ApprovedForSession,
|
||||
NetworkPolicyAmendment {
|
||||
host: String,
|
||||
action: NetworkPolicyRuleAction,
|
||||
},
|
||||
Denied,
|
||||
Abort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkApprovalContext {
|
||||
pub host: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
pub call_id: String,
|
||||
pub approval_id: String,
|
||||
pub turn_id: String,
|
||||
pub command: String,
|
||||
pub cwd: String,
|
||||
pub reason: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
#[serde(default)]
|
||||
pub proposed_execpolicy_amendment: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
#[serde(default)]
|
||||
pub additional_permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub available_decisions: Vec<ReviewDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum EventFrame {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolCallStart {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
},
|
||||
ToolCallResult {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
},
|
||||
McpStartupUpdate {
|
||||
update: McpStartupUpdateEvent,
|
||||
},
|
||||
McpStartupComplete {
|
||||
summary: McpStartupCompleteEvent,
|
||||
},
|
||||
McpToolCallBegin {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
},
|
||||
McpToolCallEnd {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
ok: bool,
|
||||
},
|
||||
ExecApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ApplyPatchApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ElicitationRequest {
|
||||
server_name: String,
|
||||
request_id: String,
|
||||
prompt: String,
|
||||
},
|
||||
ExecCommandBegin {
|
||||
command: String,
|
||||
cwd: String,
|
||||
},
|
||||
ExecCommandOutputDelta {
|
||||
command: String,
|
||||
delta: String,
|
||||
},
|
||||
ExecCommandEnd {
|
||||
command: String,
|
||||
exit_code: i32,
|
||||
},
|
||||
PatchApplyBegin {
|
||||
path: String,
|
||||
},
|
||||
PatchApplyEnd {
|
||||
path: String,
|
||||
ok: bool,
|
||||
},
|
||||
TurnStarted {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnComplete {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnAborted {
|
||||
turn_id: String,
|
||||
reason: String,
|
||||
},
|
||||
Error {
|
||||
response_id: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use deepseek_protocol::{EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams};
|
||||
|
||||
#[test]
|
||||
fn thread_resume_params_round_trip() {
|
||||
let request = ThreadRequest::Resume(ThreadResumeParams {
|
||||
thread_id: "thread-123".to_string(),
|
||||
history: None,
|
||||
path: None,
|
||||
model: Some("deepseek-reasoner".to_string()),
|
||||
model_provider: Some("deepseek".to_string()),
|
||||
cwd: None,
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox: Some("workspace-write".to_string()),
|
||||
config: None,
|
||||
base_instructions: Some("base".to_string()),
|
||||
developer_instructions: Some("dev".to_string()),
|
||||
personality: Some("default".to_string()),
|
||||
persist_extended_history: true,
|
||||
});
|
||||
|
||||
let encoded = serde_json::to_string(&request).expect("serialize request");
|
||||
let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
|
||||
match decoded {
|
||||
ThreadRequest::Resume(params) => {
|
||||
assert_eq!(params.thread_id, "thread-123");
|
||||
assert_eq!(params.model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert!(params.persist_extended_history);
|
||||
}
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_list_params_defaults_are_serializable() {
|
||||
let request = ThreadRequest::List(ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(20),
|
||||
});
|
||||
let encoded = serde_json::to_string_pretty(&request).expect("serialize list request");
|
||||
assert!(encoded.contains("include_archived"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_frame_serialization_contains_expected_tag() {
|
||||
let frame = EventFrame::TurnComplete {
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&frame).expect("serialize frame");
|
||||
assert!(encoded.contains("turn_complete"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "deepseek-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Session/thread persistence and recovery model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,950 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadMetadata {
|
||||
pub id: String,
|
||||
pub rollout_path: Option<PathBuf>,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
pub name: Option<String>,
|
||||
pub sandbox_policy: Option<String>,
|
||||
pub approval_mode: Option<String>,
|
||||
pub archived: bool,
|
||||
pub archived_at: Option<i64>,
|
||||
pub git_sha: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub git_origin_url: Option<String>,
|
||||
pub memory_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicToolRecord {
|
||||
pub position: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageRecord {
|
||||
pub id: i64,
|
||||
pub thread_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub item: Option<Value>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointRecord {
|
||||
pub thread_id: String,
|
||||
pub checkpoint_id: String,
|
||||
pub state: Value,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStateStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobStateRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: JobStateStatus,
|
||||
pub progress: Option<u8>,
|
||||
pub detail: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadListFilters {
|
||||
pub include_archived: bool,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for ThreadListFilters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SessionIndexEntry {
|
||||
thread_id: String,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateStore {
|
||||
db_path: PathBuf,
|
||||
session_index_path: PathBuf,
|
||||
}
|
||||
|
||||
impl StateStore {
|
||||
pub fn open(path: Option<PathBuf>) -> Result<Self> {
|
||||
let db_path = path.unwrap_or_else(default_state_db_path);
|
||||
let session_index_path = db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("session_index.jsonl");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create state directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let store = Self {
|
||||
db_path,
|
||||
session_index_path,
|
||||
};
|
||||
store.init_schema()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> &Path {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
fn conn(&self) -> Result<Connection> {
|
||||
Connection::open(&self.db_path)
|
||||
.with_context(|| format!("failed to open state db {}", self.db_path.display()))
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, position),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(thread_id, checkpoint_id),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
"#,
|
||||
)
|
||||
.context("failed to initialize thread schema")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
|
||||
?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path=excluded.rollout_path,
|
||||
preview=excluded.preview,
|
||||
ephemeral=excluded.ephemeral,
|
||||
model_provider=excluded.model_provider,
|
||||
created_at=excluded.created_at,
|
||||
updated_at=excluded.updated_at,
|
||||
status=excluded.status,
|
||||
path=excluded.path,
|
||||
cwd=excluded.cwd,
|
||||
cli_version=excluded.cli_version,
|
||||
source=excluded.source,
|
||||
title=excluded.title,
|
||||
sandbox_policy=excluded.sandbox_policy,
|
||||
approval_mode=excluded.approval_mode,
|
||||
archived=excluded.archived,
|
||||
archived_at=excluded.archived_at,
|
||||
git_sha=excluded.git_sha,
|
||||
git_branch=excluded.git_branch,
|
||||
git_origin_url=excluded.git_origin_url,
|
||||
memory_mode=excluded.memory_mode
|
||||
"#,
|
||||
params![
|
||||
thread.id,
|
||||
path_to_opt_string(thread.rollout_path.as_deref()),
|
||||
thread.preview,
|
||||
bool_to_i64(thread.ephemeral),
|
||||
thread.model_provider,
|
||||
thread.created_at,
|
||||
thread.updated_at,
|
||||
thread_status_to_str(&thread.status),
|
||||
path_to_opt_string(thread.path.as_deref()),
|
||||
thread.cwd.display().to_string(),
|
||||
thread.cli_version,
|
||||
session_source_to_str(&thread.source),
|
||||
thread.name,
|
||||
thread.sandbox_policy,
|
||||
thread.approval_mode,
|
||||
bool_to_i64(thread.archived),
|
||||
thread.archived_at,
|
||||
thread.git_sha,
|
||||
thread.git_branch,
|
||||
thread.git_origin_url,
|
||||
thread.memory_mode,
|
||||
],
|
||||
)
|
||||
.context("failed to upsert thread metadata")?;
|
||||
|
||||
self.append_thread_name(
|
||||
&thread.id,
|
||||
thread.name.clone(),
|
||||
thread.updated_at,
|
||||
thread.rollout_path.clone(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread(&self, id: &str) -> Result<Option<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
FROM threads
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![id],
|
||||
row_to_thread,
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread")
|
||||
}
|
||||
|
||||
pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
let sql = if filters.include_archived {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1"
|
||||
} else {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
|
||||
let limit = i64::try_from(filters.limit.unwrap_or(50)).unwrap_or(50);
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query threads")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate thread rows")? {
|
||||
out.push(row_to_thread(row)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn mark_archived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 1, archived_at = ?2, status = ?3 WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
Utc::now().timestamp(),
|
||||
thread_status_to_str(&ThreadStatus::Archived)
|
||||
],
|
||||
)
|
||||
.context("failed to archive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_unarchived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 0, archived_at = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.context("failed to unarchive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_thread(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", params![id])
|
||||
.context("failed to delete thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_thread_memory_mode(&self, id: &str, mode: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET memory_mode = ?2 WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)
|
||||
.context("failed to update thread memory mode")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread_memory_mode(&self, id: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT memory_mode FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread memory mode")
|
||||
.map(Option::flatten)
|
||||
}
|
||||
|
||||
pub fn persist_dynamic_tools(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
tools: &[DynamicToolRecord],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin dynamic tools transaction")?;
|
||||
tx.execute(
|
||||
"DELETE FROM thread_dynamic_tools WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.context("failed to clear dynamic tools")?;
|
||||
for tool in tools {
|
||||
tx.execute(
|
||||
"INSERT INTO thread_dynamic_tools(thread_id, position, name, description, input_schema) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
thread_id,
|
||||
tool.position,
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.input_schema.to_string()
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to persist dynamic tool {}", tool.name))?;
|
||||
}
|
||||
tx.commit().context("failed to commit dynamic tools")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_dynamic_tools(&self, thread_id: &str) -> Result<Vec<DynamicToolRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT position, name, description, input_schema FROM thread_dynamic_tools WHERE thread_id = ?1 ORDER BY position ASC",
|
||||
)
|
||||
.context("failed to prepare get dynamic tools query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id])
|
||||
.context("failed to query dynamic tools")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate dynamic tools")? {
|
||||
let input_schema_raw: String =
|
||||
row.get(3).context("failed to read tool input schema")?;
|
||||
let input_schema: Value =
|
||||
serde_json::from_str(&input_schema_raw).with_context(|| {
|
||||
format!("failed to parse input schema for dynamic tool in thread {thread_id}")
|
||||
})?;
|
||||
out.push(DynamicToolRecord {
|
||||
position: row.get(0).context("failed to read tool position")?,
|
||||
name: row.get(1).context("failed to read tool name")?,
|
||||
description: row.get(2).context("failed to read tool description")?,
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn append_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
item: Option<Value>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn()?;
|
||||
let created_at = Utc::now().timestamp();
|
||||
let item_json = item
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.context("failed to serialize message item payload")?;
|
||||
conn.execute(
|
||||
"INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![thread_id, role, content, item_json, created_at],
|
||||
)
|
||||
.with_context(|| format!("failed to append message for thread {thread_id}"))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_messages(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MessageRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare message listing query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list messages for thread {thread_id}"))?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate message rows")? {
|
||||
let item_json: Option<String> = row.get(4).context("failed to read item json")?;
|
||||
let item = item_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.with_context(|| {
|
||||
format!("failed to parse message item json in thread {thread_id}")
|
||||
})?;
|
||||
out.push(MessageRecord {
|
||||
id: row.get(0).context("failed to read message id")?,
|
||||
thread_id: row.get(1).context("failed to read message thread id")?,
|
||||
role: row.get(2).context("failed to read message role")?,
|
||||
content: row.get(3).context("failed to read message content")?,
|
||||
item,
|
||||
created_at: row.get(5).context("failed to read message timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn save_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: &str,
|
||||
state: &Value,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
let state_json =
|
||||
serde_json::to_string(state).context("failed to encode checkpoint state")?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(thread_id, checkpoint_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
created_at = excluded.created_at
|
||||
"#,
|
||||
params![thread_id, checkpoint_id, state_json, Utc::now().timestamp()],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to save checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: Option<&str>,
|
||||
) -> Result<Option<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
if let Some(checkpoint_id) = checkpoint_id {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| {
|
||||
format!("failed to load checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
return Ok(row);
|
||||
}
|
||||
|
||||
conn.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT 1",
|
||||
params![thread_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to load latest checkpoint for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn list_checkpoints(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare checkpoint list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list checkpoints for thread {thread_id}"))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate checkpoint rows")? {
|
||||
let state_json: String = row.get(2).context("failed to read checkpoint state json")?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
out.push(CheckpointRecord {
|
||||
thread_id: row.get(0).context("failed to read checkpoint thread id")?,
|
||||
checkpoint_id: row.get(1).context("failed to read checkpoint id")?,
|
||||
state,
|
||||
created_at: row.get(3).context("failed to read checkpoint timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO jobs(id, name, status, progress, detail, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
status = excluded.status,
|
||||
progress = excluded.progress,
|
||||
detail = excluded.detail,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
job.id,
|
||||
job.name,
|
||||
job_state_status_to_str(&job.status),
|
||||
job.progress.map(i64::from),
|
||||
job.detail,
|
||||
job.created_at,
|
||||
job.updated_at
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to upsert job {}", job.id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_job(&self, id: &str) -> Result<Option<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
let status_raw: String = row.get(2)?;
|
||||
let progress: Option<i64> = row.get(3)?;
|
||||
Ok(JobStateRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to read job {id}"))
|
||||
}
|
||||
|
||||
pub fn list_jobs(&self, limit: Option<usize>) -> Result<Vec<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs ORDER BY updated_at DESC LIMIT ?1",
|
||||
)
|
||||
.context("failed to prepare job list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query persisted jobs")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate persisted jobs")? {
|
||||
let status_raw: String = row.get(2).context("failed to read job status")?;
|
||||
let progress: Option<i64> = row.get(3).context("failed to read job progress")?;
|
||||
out.push(JobStateRecord {
|
||||
id: row.get(0).context("failed to read job id")?,
|
||||
name: row.get(1).context("failed to read job name")?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4).context("failed to read job detail")?,
|
||||
created_at: row.get(5).context("failed to read job created_at")?,
|
||||
updated_at: row.get(6).context("failed to read job updated_at")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_job(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?1", params![id])
|
||||
.with_context(|| format!("failed to delete job {id}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_rollout_path_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT rollout_path FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to lookup rollout path")
|
||||
.map(|opt| opt.flatten().map(PathBuf::from))
|
||||
}
|
||||
|
||||
pub fn append_thread_name(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = self.session_index_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create session index directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let entry = SessionIndexEntry {
|
||||
thread_id: thread_id.to_string(),
|
||||
thread_name,
|
||||
updated_at,
|
||||
rollout_path,
|
||||
};
|
||||
let encoded =
|
||||
serde_json::to_string(&entry).context("failed to serialize session index entry")?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to open session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result<Option<String>> {
|
||||
let map = self.session_index_map()?;
|
||||
Ok(map
|
||||
.get(thread_id)
|
||||
.and_then(|entry| entry.thread_name.clone()))
|
||||
}
|
||||
|
||||
pub fn find_thread_names_by_ids(
|
||||
&self,
|
||||
ids: &[String],
|
||||
) -> Result<HashMap<String, Option<String>>> {
|
||||
let map = self.session_index_map()?;
|
||||
let mut out = HashMap::new();
|
||||
for id in ids {
|
||||
let name = map.get(id).and_then(|entry| entry.thread_name.clone());
|
||||
out.insert(id.clone(), name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn find_thread_path_by_name_str(&self, name: &str) -> Result<Option<PathBuf>> {
|
||||
let map = self.session_index_map()?;
|
||||
let matched = map
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.thread_name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(name))
|
||||
})
|
||||
.max_by_key(|entry| entry.updated_at);
|
||||
Ok(matched.and_then(|entry| entry.rollout_path.clone()))
|
||||
}
|
||||
|
||||
fn session_index_map(&self) -> Result<HashMap<String, SessionIndexEntry>> {
|
||||
if !self.session_index_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to read session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut latest = HashMap::<String, SessionIndexEntry>::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.context("failed to read session index line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: SessionIndexEntry =
|
||||
serde_json::from_str(&line).context("failed to parse session index entry")?;
|
||||
latest.insert(parsed.thread_id.clone(), parsed);
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_state_db_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("state.db")
|
||||
}
|
||||
|
||||
fn bool_to_i64(value: bool) -> i64 {
|
||||
if value { 1 } else { 0 }
|
||||
}
|
||||
|
||||
fn i64_to_bool(value: i64) -> bool {
|
||||
value != 0
|
||||
}
|
||||
|
||||
fn thread_status_to_str(status: &ThreadStatus) -> &'static str {
|
||||
match status {
|
||||
ThreadStatus::Running => "running",
|
||||
ThreadStatus::Idle => "idle",
|
||||
ThreadStatus::Completed => "completed",
|
||||
ThreadStatus::Failed => "failed",
|
||||
ThreadStatus::Paused => "paused",
|
||||
ThreadStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_status_from_str(value: &str) -> ThreadStatus {
|
||||
match value {
|
||||
"running" => ThreadStatus::Running,
|
||||
"idle" => ThreadStatus::Idle,
|
||||
"completed" => ThreadStatus::Completed,
|
||||
"failed" => ThreadStatus::Failed,
|
||||
"paused" => ThreadStatus::Paused,
|
||||
"archived" => ThreadStatus::Archived,
|
||||
_ => ThreadStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_to_str(source: &SessionSource) -> &'static str {
|
||||
match source {
|
||||
SessionSource::Interactive => "interactive",
|
||||
SessionSource::Resume => "resume",
|
||||
SessionSource::Fork => "fork",
|
||||
SessionSource::Api => "api",
|
||||
SessionSource::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_from_str(value: &str) -> SessionSource {
|
||||
match value {
|
||||
"interactive" => SessionSource::Interactive,
|
||||
"resume" => SessionSource::Resume,
|
||||
"fork" => SessionSource::Fork,
|
||||
"api" => SessionSource::Api,
|
||||
_ => SessionSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_opt_string(path: Option<&Path>) -> Option<String> {
|
||||
path.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
fn job_state_status_to_str(status: &JobStateStatus) -> &'static str {
|
||||
match status {
|
||||
JobStateStatus::Queued => "queued",
|
||||
JobStateStatus::Running => "running",
|
||||
JobStateStatus::Completed => "completed",
|
||||
JobStateStatus::Failed => "failed",
|
||||
JobStateStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn job_state_status_from_str(value: &str) -> JobStateStatus {
|
||||
match value {
|
||||
"queued" => JobStateStatus::Queued,
|
||||
"running" => JobStateStatus::Running,
|
||||
"completed" => JobStateStatus::Completed,
|
||||
"failed" => JobStateStatus::Failed,
|
||||
"cancelled" => JobStateStatus::Cancelled,
|
||||
_ => JobStateStatus::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
|
||||
let status_raw: String = row.get(7)?;
|
||||
let source_raw: String = row.get(11)?;
|
||||
let rollout_path: Option<String> = row.get(1)?;
|
||||
let path: Option<String> = row.get(8)?;
|
||||
Ok(ThreadMetadata {
|
||||
id: row.get(0)?,
|
||||
rollout_path: rollout_path.map(PathBuf::from),
|
||||
preview: row.get(2)?,
|
||||
ephemeral: i64_to_bool(row.get(3)?),
|
||||
model_provider: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
status: thread_status_from_str(&status_raw),
|
||||
path: path.map(PathBuf::from),
|
||||
cwd: PathBuf::from(row.get::<_, String>(9)?),
|
||||
cli_version: row.get(10)?,
|
||||
source: session_source_from_str(&source_raw),
|
||||
name: row.get(12)?,
|
||||
sandbox_policy: row.get(13)?,
|
||||
approval_mode: row.get(14)?,
|
||||
archived: i64_to_bool(row.get(15)?),
|
||||
archived_at: row.get(16)?,
|
||||
git_sha: row.get(17)?,
|
||||
git_branch: row.get(18)?,
|
||||
git_origin_url: row.get(19)?,
|
||||
memory_mode: row.get(20)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use deepseek_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
|
||||
|
||||
fn temp_state_path(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"deepseek_state_test_{}_{}_{}.db",
|
||||
label,
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_resume_thread_metadata() {
|
||||
let path = temp_state_path("upsert_resume");
|
||||
let store = StateStore::open(Some(path.clone())).expect("open state store");
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let thread = ThreadMetadata {
|
||||
id: "thread-test-1".to_string(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
preview: "hello".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "deepseek".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: ThreadStatus::Running,
|
||||
path: Some(PathBuf::from("/tmp/project")),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
cli_version: "0.0.0-test".to_string(),
|
||||
source: SessionSource::Interactive,
|
||||
name: Some("Test Thread".to_string()),
|
||||
sandbox_policy: Some("workspace-write".to_string()),
|
||||
approval_mode: Some("on-request".to_string()),
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: Some("extended".to_string()),
|
||||
};
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
|
||||
let loaded = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read thread")
|
||||
.expect("thread must exist");
|
||||
assert_eq!(loaded.id, "thread-test-1");
|
||||
assert_eq!(loaded.name.as_deref(), Some("Test Thread"));
|
||||
assert_eq!(loaded.memory_mode.as_deref(), Some("extended"));
|
||||
assert_eq!(
|
||||
loaded.rollout_path,
|
||||
Some(PathBuf::from("/tmp/rollout.jsonl"))
|
||||
);
|
||||
|
||||
store
|
||||
.mark_archived("thread-test-1")
|
||||
.expect("archive thread");
|
||||
let archived = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read archived thread")
|
||||
.expect("thread exists after archive");
|
||||
assert!(archived.archived);
|
||||
|
||||
let listed = store
|
||||
.list_threads(ThreadListFilters {
|
||||
include_archived: true,
|
||||
limit: Some(10),
|
||||
})
|
||||
.expect("list threads");
|
||||
assert!(!listed.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "deepseek-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Tool invocation lifecycle, schema validation, and scheduler parallelism for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
deepseek-protocol = { path = "../protocol" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolSpec {
|
||||
pub name: String,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfiguredToolSpec {
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
pub raw_tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
|
||||
match &self.payload {
|
||||
ToolPayload::LocalShell { params } => (
|
||||
params.command.clone(),
|
||||
params
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_cwd.to_string()),
|
||||
"shell",
|
||||
),
|
||||
_ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolInvocation {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FunctionCallError {
|
||||
ToolNotFound { name: String },
|
||||
KindMismatch { expected: ToolKind, got: ToolKind },
|
||||
MutatingToolRejected { name: String },
|
||||
TimedOut { name: String, timeout_ms: u64 },
|
||||
Cancelled { name: String },
|
||||
ExecutionFailed { name: String, error: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ToolHandler: Send + Sync {
|
||||
fn kind(&self) -> ToolKind;
|
||||
fn matches_kind(&self, kind: ToolKind) -> bool {
|
||||
self.kind() == kind
|
||||
}
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ToolCallRuntime {
|
||||
pub parallel_execution: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ToolRegistry {
|
||||
handlers: HashMap<String, Arc<dyn ToolHandler>>,
|
||||
specs: HashMap<String, ConfiguredToolSpec>,
|
||||
runtime: ToolCallRuntime,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn register(&mut self, spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Result<()> {
|
||||
let name = spec.name.clone();
|
||||
self.specs.insert(
|
||||
name.clone(),
|
||||
ConfiguredToolSpec {
|
||||
supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
|
||||
spec,
|
||||
},
|
||||
);
|
||||
self.handlers.insert(name, handler);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_specs(&self) -> Vec<ConfiguredToolSpec> {
|
||||
self.specs.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
call: ToolCall,
|
||||
allow_mutating: bool,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
|
||||
FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
}
|
||||
})?;
|
||||
let configured =
|
||||
self.specs
|
||||
.get(&call.name)
|
||||
.cloned()
|
||||
.ok_or_else(|| FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
})?;
|
||||
|
||||
let payload_kind = tool_payload_kind(&call.payload);
|
||||
let expected = handler.kind();
|
||||
if !handler.matches_kind(payload_kind) {
|
||||
return Err(FunctionCallError::KindMismatch {
|
||||
expected,
|
||||
got: payload_kind,
|
||||
});
|
||||
}
|
||||
if handler.is_mutating() && !allow_mutating {
|
||||
return Err(FunctionCallError::MutatingToolRejected { name: call.name });
|
||||
}
|
||||
|
||||
let invocation = ToolInvocation {
|
||||
call_id: call
|
||||
.raw_tool_call_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
|
||||
tool_name: call.name.clone(),
|
||||
payload: call.payload,
|
||||
source: call.source,
|
||||
};
|
||||
|
||||
if configured.supports_parallel_tool_calls {
|
||||
let _guard = self.runtime.parallel_execution.read().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
} else {
|
||||
let _guard = self.runtime.parallel_execution.write().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_with_timeout(
|
||||
&self,
|
||||
handler: Arc<dyn ToolHandler>,
|
||||
timeout_ms: Option<u64>,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
let name = invocation.tool_name.clone();
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
handler.handle(invocation),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
|
||||
}
|
||||
} else {
|
||||
handler.handle(invocation).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
|
||||
match payload {
|
||||
ToolPayload::Mcp { .. } => ToolKind::Mcp,
|
||||
ToolPayload::Function { .. }
|
||||
| ToolPayload::Custom { .. }
|
||||
| ToolPayload::LocalShell { .. } => ToolKind::Function,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use deepseek_tools::{
|
||||
ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct EchoHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for EchoHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, deepseek_tools::FunctionCallError> {
|
||||
Ok(ToolOutput::Function {
|
||||
body: Some(json!({
|
||||
"tool": invocation.tool_name,
|
||||
"call_id": invocation.call_id
|
||||
})),
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatches_function_tool_with_parallel_flag() {
|
||||
let mut registry = ToolRegistry::default();
|
||||
registry
|
||||
.register(
|
||||
ToolSpec {
|
||||
name: "echo".to_string(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
output_schema: json!({"type":"object"}),
|
||||
supports_parallel_tool_calls: true,
|
||||
timeout_ms: Some(1000),
|
||||
},
|
||||
Arc::new(EchoHandler),
|
||||
)
|
||||
.expect("register tool");
|
||||
|
||||
let output = registry
|
||||
.dispatch(
|
||||
ToolCall {
|
||||
name: "echo".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{\"message\":\"hi\"}".to_string(),
|
||||
},
|
||||
source: ToolCallSource::Direct,
|
||||
raw_tool_call_id: Some("call-1".to_string()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("dispatch tool");
|
||||
match output {
|
||||
ToolOutput::Function { success, .. } => assert!(success),
|
||||
other => panic!("unexpected output: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "deepseek-tui-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Event-driven TUI state machine scaffold for DeepSeek workspace architecture"
|
||||
@@ -0,0 +1,192 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Pane {
|
||||
Chat,
|
||||
Diff,
|
||||
Tasks,
|
||||
Agents,
|
||||
Status,
|
||||
Jobs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEvent {
|
||||
KeyPressed(char),
|
||||
PromptSubmitted(String),
|
||||
ResponseDelta(String),
|
||||
ToolStarted(String),
|
||||
ToolFinished(String),
|
||||
JobQueued(String),
|
||||
JobProgress { job_id: String, progress: u8 },
|
||||
JobCompleted(String),
|
||||
ApprovalRequested(String),
|
||||
ApprovalResolved(String),
|
||||
PauseRequested,
|
||||
ResumeRequested,
|
||||
Tick,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEffect {
|
||||
Render,
|
||||
PersistCheckpoint,
|
||||
ScheduleBackgroundRefresh,
|
||||
EmitStatusLine(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UiState {
|
||||
pub active_pane: Pane,
|
||||
pub paused: bool,
|
||||
pub last_response_delta: Option<String>,
|
||||
pub active_tool: Option<String>,
|
||||
pub pending_tasks: usize,
|
||||
pub active_jobs: usize,
|
||||
pub pending_approvals: usize,
|
||||
pub status_line: String,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_pane: Pane::Chat,
|
||||
paused: false,
|
||||
last_response_delta: None,
|
||||
active_tool: None,
|
||||
pending_tasks: 0,
|
||||
active_jobs: 0,
|
||||
pending_approvals: 0,
|
||||
status_line: "ready".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn reduce(&mut self, event: UiEvent) -> Vec<UiEffect> {
|
||||
match event {
|
||||
UiEvent::KeyPressed('1') => {
|
||||
self.active_pane = Pane::Chat;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('2') => {
|
||||
self.active_pane = Pane::Diff;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('3') => {
|
||||
self.active_pane = Pane::Tasks;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('4') => {
|
||||
self.active_pane = Pane::Agents;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('5') => {
|
||||
self.active_pane = Pane::Jobs;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::PromptSubmitted(_) => {
|
||||
self.pending_tasks = self.pending_tasks.saturating_add(1);
|
||||
self.status_line = "prompt submitted".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResponseDelta(delta) => {
|
||||
self.last_response_delta = Some(delta);
|
||||
self.status_line = "streaming response".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolStarted(name) => {
|
||||
self.active_tool = Some(name.clone());
|
||||
self.status_line = format!("tool running: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolFinished(name) => {
|
||||
self.active_tool = None;
|
||||
self.pending_tasks = self.pending_tasks.saturating_sub(1);
|
||||
self.status_line = format!("tool finished: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobQueued(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_add(1);
|
||||
self.status_line = "job queued".to_string();
|
||||
vec![UiEffect::Render, UiEffect::PersistCheckpoint]
|
||||
}
|
||||
UiEvent::JobProgress { progress, .. } => {
|
||||
self.status_line = format!("job progress: {}%", progress.min(100));
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobCompleted(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_sub(1);
|
||||
self.status_line = "job completed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalRequested(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_add(1);
|
||||
self.status_line = "approval requested".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalResolved(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_sub(1);
|
||||
self.status_line = "approval resolved".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::PauseRequested => {
|
||||
self.paused = true;
|
||||
self.status_line = "paused".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResumeRequested => {
|
||||
self.paused = false;
|
||||
self.status_line = "resumed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::Tick => vec![UiEffect::ScheduleBackgroundRefresh],
|
||||
UiEvent::KeyPressed(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> String {
|
||||
format!(
|
||||
"pane={:?};paused={};pending_tasks={};active_jobs={};pending_approvals={};active_tool={};status={}",
|
||||
self.active_pane,
|
||||
self.paused,
|
||||
self.pending_tasks,
|
||||
self.active_jobs,
|
||||
self.pending_approvals,
|
||||
self.active_tool.clone().unwrap_or_default(),
|
||||
self.status_line
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use deepseek_tui_core::{Pane, UiEvent, UiState};
|
||||
|
||||
#[test]
|
||||
fn reducer_produces_stable_snapshot_for_core_workflow() {
|
||||
let mut state = UiState::default();
|
||||
state.reduce(UiEvent::PromptSubmitted("hello".to_string()));
|
||||
state.reduce(UiEvent::ToolStarted("web.search".to_string()));
|
||||
state.reduce(UiEvent::ResponseDelta("partial".to_string()));
|
||||
state.reduce(UiEvent::ToolFinished("web.search".to_string()));
|
||||
state.reduce(UiEvent::ApprovalRequested("approval-1".to_string()));
|
||||
state.reduce(UiEvent::ApprovalResolved("approval-1".to_string()));
|
||||
state.reduce(UiEvent::JobQueued("job-1".to_string()));
|
||||
state.reduce(UiEvent::JobProgress {
|
||||
job_id: "job-1".to_string(),
|
||||
progress: 60,
|
||||
});
|
||||
state.reduce(UiEvent::JobCompleted("job-1".to_string()));
|
||||
state.reduce(UiEvent::KeyPressed('5'));
|
||||
|
||||
assert_eq!(state.active_pane, Pane::Jobs);
|
||||
assert_eq!(
|
||||
state.snapshot(),
|
||||
"pane=Jobs;paused=false;pending_tasks=0;active_jobs=0;pending_approvals=0;active_tool=;status=job completed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Terminal UI for DeepSeek"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek-tui"
|
||||
path = "../../src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
@@ -0,0 +1,35 @@
|
||||
# Parity CI and release checks
|
||||
|
||||
This repository now includes parity-oriented CI checks under `.github/workflows/parity.yml`.
|
||||
|
||||
## Workflow coverage
|
||||
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo check --workspace --all-targets --locked`
|
||||
- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`
|
||||
- `cargo test --workspace --all-features --locked`
|
||||
- TUI snapshot parity test:
|
||||
- `cargo test -p deepseek-tui-core --test snapshot --locked`
|
||||
- protocol parity smoke test:
|
||||
- `cargo test -p deepseek-protocol --test parity_protocol --locked`
|
||||
- state persistence parity smoke test:
|
||||
- `cargo test -p deepseek-state --test parity_state --locked`
|
||||
- lockfile drift guard:
|
||||
- `git diff --exit-code -- Cargo.lock`
|
||||
|
||||
The tag-based release workflow now runs the same parity preflight before building artifacts.
|
||||
|
||||
## Expected contributor flow
|
||||
|
||||
1. Update workspace crates (`core`, `app-server`, `protocol`, `state`, `tools`, `mcp`, `execpolicy`, `hooks`, `tui`, `cli`).
|
||||
2. Keep protocol and persistence tests green for parity-sensitive changes.
|
||||
3. Ensure thread/tool/mcp event contracts remain backward-compatible across app-server endpoints.
|
||||
|
||||
## Release readiness checklist
|
||||
|
||||
- CLI and app-server binaries compile from workspace members.
|
||||
- Session persistence schema changes include migration-safe SQL updates.
|
||||
- Protocol changes include test updates in `crates/protocol/tests`.
|
||||
- New tool lifecycle behavior includes tests in `crates/tools/tests`.
|
||||
- TUI reducer changes include deterministic snapshot updates in `crates/tui/tests`.
|
||||
- Release artifacts include `deepseek` (CLI) and `deepseek-tui` (TUI) binaries for all platforms.
|
||||
@@ -0,0 +1,90 @@
|
||||
# DeepSeek Workspace Migration Status
|
||||
|
||||
This document maps the initial workspace migration implementation to Linear issues `SHA-1554` to `SHA-1568`.
|
||||
|
||||
## Implemented in this patch
|
||||
|
||||
- `SHA-1554`:
|
||||
- Root converted to Cargo workspace.
|
||||
- New crate boundaries added:
|
||||
- `crates/core`
|
||||
- `crates/cli`
|
||||
- `crates/app-server`
|
||||
- `crates/protocol`
|
||||
- `crates/config`
|
||||
- `crates/agent`
|
||||
- `crates/tui`
|
||||
- `crates/tui` (TUI binary pointing at monolith source)
|
||||
- Stable entry binaries now follow `cli` + `app-server` + `tui` split.
|
||||
|
||||
- `SHA-1555`:
|
||||
- Added `deepseek-config` crate with `ConfigToml` schema.
|
||||
- Added provider-aware env precedence (`DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, provider/base-url/model overrides).
|
||||
- Added config read/write/list/set/unset operations.
|
||||
|
||||
- `SHA-1556`:
|
||||
- Added codex-style command grouping in `deepseek` CLI:
|
||||
- `run`
|
||||
- `auth`
|
||||
- `config`
|
||||
- `model`
|
||||
- `app-server`
|
||||
- `completion`
|
||||
- Added global runtime override flags (`provider`, `model`, logging/telemetry/output/sandbox/approval controls).
|
||||
|
||||
- `SHA-1557`:
|
||||
- Added dual-provider auth model (`deepseek` + `openai`) with clear precedence and CLI management commands.
|
||||
- Added `auth status|set|clear` command flow.
|
||||
|
||||
- `SHA-1558`:
|
||||
- Added `deepseek-protocol` crate with `thread/app/prompt` request-response framing and event frames.
|
||||
- Added `deepseek-app-server` with `/thread`, `/app`, `/prompt`, `/healthz`.
|
||||
- Added `/tool`, `/jobs`, and `/mcp/startup` transport endpoints for tool/job/MCP parity flows.
|
||||
- Added stdio JSON-RPC 2.0 parity framing (`id`/`method`/`params` -> `result`/`error`) for `thread/*`, `app/*`, `prompt/*`, plus `healthz`/capabilities handlers.
|
||||
|
||||
- `SHA-1560`:
|
||||
- Added `deepseek-agent` model/provider registry with alias resolution and fallback strategy.
|
||||
|
||||
- `SHA-1564`:
|
||||
- Added `deepseek-tui-core` event-driven state machine scaffold (`UiState::reduce`).
|
||||
- Expanded reducer with job/approval states and deterministic snapshot support.
|
||||
|
||||
- `SHA-1559`:
|
||||
- Added `deepseek-state` crate with persistent thread/session metadata in SQLite.
|
||||
- Added thread list/read/archive/unarchive/name persistence operations and session index mirror.
|
||||
|
||||
- `SHA-1561`:
|
||||
- Added `deepseek-tools` crate with typed tool specs, call lifecycle, mutating gate, timeout handling, and read/write lock parallelism model.
|
||||
|
||||
- `SHA-1562`:
|
||||
- Added `deepseek-mcp` crate with server lifecycle events, qualified tool naming, filter support, resource listing/reads, and proxy call API.
|
||||
- Added MCP stdio JSON-RPC 2.0 server mode parity for `tools/list`, `tools/call`, `resources/list`, `resources/read`, and server lifecycle operations.
|
||||
- Added persisted MCP server definition round-trip through existing config APIs so server-mode definitions survive restarts.
|
||||
|
||||
- `SHA-1563`:
|
||||
- Added `deepseek-execpolicy` crate with approval mode model and policy decision/requirement evaluation.
|
||||
|
||||
- `SHA-1565`:
|
||||
- Added durable-style `JobManager` abstraction in core for queue/progress/cancel/recovery semantics.
|
||||
|
||||
- `SHA-1566`:
|
||||
- Added `deepseek-hooks` crate with stdout/jsonl/webhook sinks and standardized lifecycle events.
|
||||
|
||||
- `SHA-1567`:
|
||||
- Added parity tests for protocol/state/tools and TUI snapshot behavior.
|
||||
|
||||
- `SHA-1568`:
|
||||
- Added parity CI workflow at `.github/workflows/parity.yml` with workspace fmt/check/clippy/test gates, lockfile drift guard, and explicit snapshot/protocol/state parity tests.
|
||||
- Added matching release preflight parity gates in `.github/workflows/release.yml`.
|
||||
- Updated release artifact naming to include explicit `deepseek` entrypoint compatibility.
|
||||
|
||||
## Not yet implemented in this patch
|
||||
|
||||
- Codex-level protocol field-by-field parity for every `thread/*` operation remains in progress.
|
||||
- MCP transport now provides stdio JSON-RPC compatibility flows; external subprocess execution remains scaffolded.
|
||||
- Execution policy supports decision modeling and command gating; full user-interactive approval UX remains in progress.
|
||||
- Background jobs are persisted conceptually at runtime boundary; cross-process recovery orchestration is still in progress.
|
||||
|
||||
## Migration strategy note
|
||||
|
||||
`crates/tui` intentionally points at existing `src/main.rs` to preserve current behavior while new workspace crates are phased in. This enables incremental replacement without blocking ongoing feature work.
|
||||
Reference in New Issue
Block a user