diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b20856341..372ceb166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,62 @@ jobs: CARGO_TERM_COLOR: always run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml integration_commands::mcp --lib + headless-rust: + name: Headless Rust Check + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - name: Install protobuf compiler + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/agent-gui/src-tauri + + # P1.2: `--no-default-features` strips the whole Tauri runtime and runs + # the same business code over the axum HTTP/WebSocket bridge (see + # lib.rs `headless` module). Guard that build path against regressions; + # desktop-only deps must never leak into the headless feature set. + - name: Check headless backend + env: + CARGO_TERM_COLOR: always + run: cargo check --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features + + - name: Test headless backend + env: + CARGO_TERM_COLOR: always + run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features --lib + + - name: Build headless release binary + env: + CARGO_TERM_COLOR: always + run: cargo build --release --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features + + gen-verify: + name: Generator Drift Check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + # Regenerate adapters.rs from the committed command manifest and assert + # the checked-in file is byte-identical (no drift). Also assert headless.rs + # dispatch arms cover every manifest command (and nothing more). + - name: Regenerate adapters.rs from manifest + run: bash scripts/gen_headless.sh + + - name: No drift in adapters.rs + run: git diff --exit-code crates/agent-gui/src-tauri/src/commands/adapters.rs + + - name: Dispatch coverage vs manifest + run: python3 scripts/verify_headless.py + mirror: name: GUI/WebUI Mirror Check runs-on: ubuntu-latest diff --git a/.github/workflows/liveagent-headless-tools.yml b/.github/workflows/liveagent-headless-tools.yml new file mode 100644 index 000000000..128e5ffa9 --- /dev/null +++ b/.github/workflows/liveagent-headless-tools.yml @@ -0,0 +1,80 @@ +name: LiveAgent Headless Tools Image (minimal/core/full) + +# 构建并发布三个镜像到 GHCR(main 分支 push / v* tag 触发,可手动 dispatch): +# ghcr.io//liveagent-minimal (~0.7 GB, 仅基础工具 + liveagent,生产部署推荐) +# ghcr.io//liveagent-core (~1.8 GB, 默认开发沙箱) +# ghcr.io//liveagent-full (~2.2 GB, 含 Java 17 + Maven, Java 8 懒加载) +# 同一 Dockerfile(Dockerfile.headless-tools),用 TARGET_PROFILE 区分; +# minimal/core/full 共享 base 层,pull 时不重复下载。 + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: Existing release tag to publish, for example v0.1.0 + required: false + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build and Push (${{ matrix.profile }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - profile: minimal + image: ghcr.io/${{ github.repository_owner }}/liveagent-minimal + - profile: core + image: ghcr.io/${{ github.repository_owner }}/liveagent-core + - profile: full + image: ghcr.io/${{ github.repository_owner }}/liveagent-full + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ matrix.image }} + flavor: | + latest=false + tags: | + type=raw,value=${{ github.event.inputs.tag || github.ref_name }} + type=raw,value=latest,enable=${{ github.event.inputs.tag == '' && startsWith(github.ref, 'refs/tags/') }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.headless-tools + build-args: | + TARGET_PROFILE=${{ matrix.profile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false diff --git a/1 b/1 new file mode 100644 index 000000000..e69de29bb diff --git a/Cargo.lock b/Cargo.lock index 3bb8b3ead..a5e366eeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -332,14 +333,17 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1 0.10.6", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -2573,6 +2577,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3364,6 +3374,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.29.0", "toml 0.9.12+spec-1.1.0", + "tower-http", "uuid", "wait-timeout", "walkdir", @@ -3478,6 +3489,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3568,6 +3589,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multimap" version = "0.10.1" @@ -6110,6 +6148,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.8.0" @@ -7131,10 +7175,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -7335,6 +7388,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Dockerfile.headless-tools b/Dockerfile.headless-tools new file mode 100644 index 000000000..e0ca31e4b --- /dev/null +++ b/Dockerfile.headless-tools @@ -0,0 +1,231 @@ +# syntax=docker/dockerfile:1.7 + +# 构建 profile(minimal / core / full),必须在第一个 FROM 之前声明。 +ARG TARGET_PROFILE=core + +# apt 镜像源(国内构建时传 --build-arg APT_MIRROR=mirrors.tuna.tsinghua.edu.cn; +# 默认官方源 deb.debian.org,GitHub Actions 可达)。 +ARG APT_MIRROR=deb.debian.org + +# LiveAgent Headless — 开发工具增强镜像(minimal / core / full) +# +# 设计哲学(对齐 GitHub devcontainers / Gitpod 的成熟做法:镜像保持克制, +# 工具按需分层,而不是把所有语言运行时都塞进一个镜像): +# +# base : Debian bookworm-slim + 高频基础工具(git / 编译链 / 调试 / 网络 / 编辑器) +# minimal : base + liveagent 二进制,无任何语言运行时(生产部署推荐) (~0.7 GB) +# core : minimal + mise + go 1.25.12 + node 22.19.0 + python 3.12 + pnpm + bun (~1.8 GB) +# full : core + java temurin-17 + maven 3.9 (~2.2 GB) +# +# minimal 与 core/full 的差异只有语言运行时:liveagent 主服务是 Rust 静态二进制, +# entrypoint 对 mise 是可选依赖(见 docker/entrypoint.sh),minimal 可直接跑主服务。 +# 语言运行时全部由 mise 管理(镜像内置全局 config,见 docker/mise.{core,full}.toml), +# 用户可在 compose 里用 MISE__VERSION 环境变量切换任意版本,缺失的版本 +# 首次启动自动补装(懒加载,见 docker/entrypoint.sh)。 +# 注意:go/node/python 由 mise core backend 从 GitHub/go.dev/nodejs.org 下载, +# pnpm/bun 走 npm backend,registry 指向 npmmirror(MISE_NPM_REGISTRY_URL), +# 国内网络下 npm 类工具安装/补装不依赖 GitHub。 +# +# 构建(GitHub Actions,官方源可达;用 TARGET_PROFILE 选择 minimal / core / full): +# docker build --build-arg TARGET_PROFILE=minimal -f Dockerfile.headless-tools -t liveagent-minimal . +# docker build --build-arg TARGET_PROFILE=core -f Dockerfile.headless-tools -t liveagent-core . +# docker build --build-arg TARGET_PROFILE=full -f Dockerfile.headless-tools -t liveagent-full . + +# ---- Frontend stage: build the WebUI dist on the native builder ---- +FROM --platform=$BUILDPLATFORM node:22.19.0-bookworm-slim AS frontend + +WORKDIR /app + +# pnpm 10 requires pnpm-workspace.yaml (allowBuilds). Copy manifests first +# for layer caching, then the sources needed by `pnpm build` (tsc + vite). +COPY crates/agent-gui/package.json crates/agent-gui/pnpm-lock.yaml crates/agent-gui/pnpm-workspace.yaml ./ +RUN npm install -g pnpm@10.32.1 && pnpm install --frozen-lockfile + +COPY crates/agent-gui/index.html crates/agent-gui/vite.config.ts \ + crates/agent-gui/tsconfig.json crates/agent-gui/tsconfig.node.json \ + crates/agent-gui/postcss.config.js crates/agent-gui/tailwind.config.js ./ +COPY crates/agent-gui/src ./src +COPY crates/agent-gui/public ./public +COPY crates/agent-gui/src-tauri/icons ./src-tauri/icons + +RUN pnpm build + +# ---- Builder stage: compile the headless binary with embedded WebUI ---- +FROM --platform=$BUILDPLATFORM rust:1.97-bookworm AS builder + +# Install system dependencies for the build +ARG APT_MIRROR=deb.debian.org +# gai.conf: 强制 IPv4 优先(部分国内环境 IPv6 解析通但出网不通,apt 会失败) +RUN (sed -i 's/^#\s*precedence ::ffff:0:0\/96 100/precedence ::ffff:0:0\/96 100/' /etc/gai.conf 2>/dev/null || echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf) && \ + sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list 2>/dev/null || true && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + libclang-dev \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# Auto-provided build args (buildx). Declared before the cache-mount RUN +# layers so $TARGETARCH is available for per-arch cache ids. +ARG TARGETOS +ARG TARGETARCH + +# Copy workspace Cargo files for dependency caching +COPY Cargo.toml Cargo.lock ./ +COPY crates/agent-gui/src-tauri/Cargo.toml ./crates/agent-gui/src-tauri/ + +# Create dummy source files for dependency caching +RUN mkdir -p crates/agent-gui/src-tauri/src && \ + echo 'fn main() {}' > crates/agent-gui/src-tauri/src/main.rs && \ + echo 'pub fn dummy() {}' > crates/agent-gui/src-tauri/src/lib.rs + +# Build dependencies only (cached layer) +# NOTE: per-arch cache ids prevent parallel amd64/arm64 builders from +# racing on the same cargo registry dir (tower-*.cargo-ok File exists). +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry-$TARGETARCH \ + --mount=type=cache,target=/src/target,id=cargo-target-$TARGETARCH \ + cargo build --release --no-default-features -p liveagent || true + +# Copy actual source code +COPY . . + +# Copy the WebUI dist built in the frontend stage so build.rs embeds it. +# (.dockerignore excludes **/dist, so this is the only dist in the image.) +COPY --from=frontend /app/dist ./crates/agent-gui/dist + +# Build the headless binary with embedded assets +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry-$TARGETARCH \ + --mount=type=cache,target=/src/target,id=cargo-target-$TARGETARCH \ + cargo build --release --no-default-features -p liveagent && \ + cp target/release/liveagent /usr/local/bin/liveagent + +# ---- Base: 运行环境 + 高频基础工具链 ---- +# 只预装高频且体积可控的工具;低频大件(gdb/valgrind/clang/rust/php/ruby 等) +# 不预装,需要时 apt 或 mise 按需安装(见 README "开发工具镜像" 一节)。 +FROM debian:bookworm-slim AS base + +ARG APT_MIRROR=deb.debian.org +# gai.conf: 强制 IPv4 优先(部分国内环境 IPv6 解析通但出网不通,apt 会失败) +RUN (sed -i 's/^#\s*precedence ::ffff:0:0\/96 100/precedence ::ffff:0:0\/96 100/' /etc/gai.conf 2>/dev/null || echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf) && \ + sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list 2>/dev/null || true && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + bash \ + git \ + curl \ + wget \ + unzip \ + xz-utils \ + zstd \ + locales \ + build-essential \ + cmake \ + pkg-config \ + ninja-build \ + strace \ + vim \ + tmux \ + less \ + nano \ + file \ + jq \ + procps \ + iputils-ping \ + dnsutils \ + iproute2 \ + netcat-openbsd \ + tcpdump \ + && rm -rf /var/lib/apt/lists/* + +# 生成 en_US.UTF-8 locale(默认 POSIX 环境影响部分中文/国际化工具) +RUN sed -i 's/^# \(en_US.UTF-8\)/\1/' /etc/locale.gen && locale-gen + +# liveagent 用户提前在 base 创建(uid 10001 固定,三个 profile 共用): +# core/full 的 mise 运行时将以该 uid 安装,/opt/mise 属主写入时即正确, +# final 无需再 chown -R /opt/mise —— 否则 overlayfs 会对整个目录树做 +# copy-up,镜像白白多出 ~1GB(chown 改 inode 元数据必然触发整树复制)。 +RUN useradd --system --uid 10001 --user-group --create-home \ + --home-dir /var/lib/liveagent --shell /bin/bash liveagent + +# ---- Minimal: base + liveagent 二进制(无任何语言运行时,生产部署推荐) ---- +FROM base AS minimal + +# ---- Core: mise + 高频开发语言运行时 ---- +FROM base AS core + +# mise 版本管理器(单二进制)。GitHub Actions 官方源可达;MISE_VERSION 锁定可复现。 +# 先下载到文件再执行:curl -f 失败会直接中断构建,不会因管道空输入被吞。 +# MISE_NPM_REGISTRY_URL:npm backend(pnpm/bun)统一走 npmmirror, +# 保证国内网络(NAS/容器)下安装与懒加载补装都不依赖 GitHub。 +ENV MISE_DATA_DIR=/opt/mise \ + MISE_GLOBAL_CONFIG_FILE=/etc/mise/config.toml \ + MISE_NPM_REGISTRY_URL=https://registry.npmmirror.com \ + MISE_YES=true + +RUN curl -fsSL https://mise.run -o /tmp/mise-install.sh && \ + MISE_VERSION=v2026.4.27 MISE_INSTALL_PATH=/usr/local/bin/mise sh /tmp/mise-install.sh && \ + rm /tmp/mise-install.sh && \ + mise --version + +# 预装 core 默认工具链:go / node / pnpm / python(python 走预编译二进制,不编译源码) +# 以 liveagent(uid 10001) 身份安装:/opt/mise 写入时属主即正确,final 不再 chown -R +# (否则 overlayfs 整树 copy-up 额外 ~1GB 层)。安装后清掉 npm/core 下载缓存 +# (~/.npm ~/.cache),镜像瘦身;懒加载补装按需重新下载。 +# 注意:不用 COPY --chmod —— 某些 overlayfs 驱动下 BuildKit 的 --chmod 会产出 +# 非 root 不可读的文件;构建时 RUN chmod(触发 copy-up)才可靠。 +COPY docker/mise.core.toml /etc/mise/config.toml +RUN chmod 0644 /etc/mise/config.toml && \ + install -d -o liveagent -g liveagent /opt/mise +USER liveagent +RUN mise install --jobs 4 && \ + rm -rf /var/lib/liveagent/.npm /var/lib/liveagent/.cache +USER root + +# ---- Full: Core + Java 工具链(JDK 17 默认 + Maven;Java 8 懒加载见 README) ---- +FROM core AS full + +COPY docker/mise.full.toml /etc/mise/config.toml +RUN chmod 0644 /etc/mise/config.toml +USER liveagent +RUN mise install --jobs 4 && \ + rm -rf /var/lib/liveagent/.npm /var/lib/liveagent/.cache +USER root + +# ---- Final: 按 TARGET_PROFILE 选择 minimal / core / full ---- +FROM ${TARGET_PROFILE} AS final + +COPY --from=builder /usr/local/bin/liveagent /usr/local/bin/liveagent +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh + +RUN chmod 755 /usr/local/bin/entrypoint.sh && \ + # liveagent 用户已在 base 创建;home 属主纠正 + 收紧权限(数据在 $HOME/.liveagent) + install -d -o liveagent -g liveagent -m 0700 /var/lib/liveagent && \ + # --- mise 环境注入(仅 core/full 含 mise 时写入;minimal 无 mise 跳过)--- + # 1) /etc/profile.d/mise.sh:login shell 无论交互与否都加载(/etc/profile 会 + # source profile.d/*.sh),覆盖 liveagent 的 bash -lc 执行路径; + # 2) /etc/bash.bashrc 尾部:覆盖非登录交互式 shell(docker exec -it bash)。 + # 两者叠加即所有 bash 场景都有完整工具链。 + if command -v mise >/dev/null 2>&1; then \ + printf 'eval "$(mise env --shell bash)" 2>/dev/null\n' > /etc/profile.d/mise.sh && \ + echo 'eval "$(mise env --shell bash)" 2>/dev/null' >> /etc/bash.bashrc; \ + fi + +USER liveagent + +# 兜底:把 mise shims 放进 PATH,覆盖不经 shell 直接 fork 的进程; +# 正常 bash 场景由 profile.d/bash.bashrc 的 mise env 注入(含 installs 路径)。 +ENV PATH=/opt/mise/shims:$PATH \ + LIVEAGENT_HEADLESS_HOST=0.0.0.0 \ + LIVEAGENT_HEADLESS_PORT=17890 \ + LIVEAGENT_DATA_DIR=/var/lib/liveagent \ + LANG=en_US.UTF-8 + +VOLUME ["/var/lib/liveagent"] + +EXPOSE 17890 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index 8f560fc21..b3b7f2203 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,90 @@ location / { +### Headless Dev-Tools Image (`minimal` / `core` / `full`) + +A drop-in development sandbox built on the headless runtime. Instead of one ever-growing "kitchen-sink" image, the toolchain is **layered and kept lean** (mirroring the GitHub devcontainers / Gitpod approach): + +| Image | Contents | Approx. size | +|---|---|---| +| `liveagent-minimal` | base tools (git · build-essential · cmake · ninja · pkg-config · strace · vim · tmux · network tools) **+** liveagent binary, **no language runtimes** — recommended for production | ~0.7 GB | +| `liveagent-core` | everything in `minimal` **+** go 1.25.12 · node 22.19.0 · pnpm · bun · python 3.12 (all managed by [mise](https://mise.jdx.dev)) | ~1.8 GB | +| `liveagent-full` | everything in `core` **+** Java (Temurin 17) · Maven 3.9 | ~2.2 GB | + +All images are built by GitHub Actions from the same `Dockerfile.headless-tools` (`TARGET_PROFILE=minimal|core|full`), multi-arch amd64/arm64, and share the same base layers — pulling `full` never re-downloads the `core` layers. The headless server itself is a static Rust binary, so `minimal` runs the full service with no runtimes at all. + +**Quick start (compose):** + +```yaml +services: + liveagent: + image: ghcr.io/stack-cairn/liveagent-full:latest + restart: unless-stopped + ports: + - "17890:17890" + volumes: + - liveagent-data:/var/lib/liveagent + # Named volume (not bind mount!) — Docker copies the preinstalled + # toolchain into it on first use and persists any lazily-installed + # runtimes (e.g. Java 8) across restarts. + - mise-data:/opt/mise +volumes: + liveagent-data: + mise-data: +``` + +**Switch any runtime version via an environment variable.** The image reads `MISE__VERSION` (e.g. `MISE_JAVA_VERSION`, `MISE_NODE_VERSION`, `MISE_PYTHON_VERSION`); missing versions are auto-installed on first start (needs network once) and persisted on the `mise-data` volume: + +```yaml +services: + liveagent: + image: ghcr.io/stack-cairn/liveagent-full:latest + environment: + MISE_JAVA_VERSION: "temurin-8" # switch to Java 8; auto-installed on first boot + volumes: + - liveagent-data:/var/lib/liveagent + - mise-data:/opt/mise # persists the lazily-installed JDK +``` + +> **Notes** +> - Use a **named volume** for `/opt/mise`. A bind mount of an empty directory would hide the preinstalled toolchain. +> - Low-frequency / large tools are intentionally **not** preinstalled (gdb, valgrind, clang, rust, php, ruby…). Install them on demand: `apt-get install -y gdb clang` or `mise use -g rust@latest` — no image rebuild needed. +> - Every bash session inside the container has the full mise environment (PATH / JAVA_HOME), including **non-interactive login shells** like the app's `bash -lc` execution path: `/etc/profile.d/mise.sh` covers login shells, `/etc/bash.bashrc` covers interactive shells, and `/opt/mise/shims` is on the default PATH as a fallback for non-shell processes. +> - `pnpm` and `bun` are installed via the npm backend from the npmmirror registry (`MISE_NPM_REGISTRY_URL`), so installing/upgrading them does not depend on GitHub reachability; `go`/`node`/`python` come from their official upstreams (preinstalled in the image). + +### Headless Security Model + +The headless server serves the WebUI, the HTTP API and the WebSocket event stream on one port (`LIVEAGENT_HEADLESS_PORT`, default 17890). Access control: + +| Env var | Default | Effect | +|---|---|---| +| `LIVEAGENT_API_TOKEN` | *(unset = auth off)* | Enables Bearer auth for `/api/invoke` and requires `?token=` on non-browser `/ws` connections. | +| `LIVEAGENT_HEADLESS_HOST` | `127.0.0.1` | Bind address. Binding a non-loopback interface **without** a token prints a startup warning. | +| `LIVEAGENT_HEADLESS_CORS_ORIGINS` | *(unset)* | Comma-separated extra origins allowed to call the API (besides the same origin). | +| `LIVEAGENT_TRUST_PROXY_HEADERS` | *(unset)* | Set to `1` to trust `X-Forwarded-For` for rate-limit IPs (only behind a trusted reverse proxy). | + +- **Origin gate (default on):** every request with an `Origin` header is allowed only if it matches the server's own origin or `LIVEAGENT_HEADLESS_CORS_ORIGINS`; anything else gets `403`. Preflight `OPTIONS` is answered with the matching CORS headers. This blocks CSRF and cross-origin data exfiltration. +- **Same-origin exemption:** requests without an `Origin` (curl, scripts) pass the gate; when `LIVEAGENT_API_TOKEN` is set they must present `Authorization: Bearer ` (invoke) or `?token=` (WebSocket). Browser pages served by the server itself are always allowed (same origin), so the WebUI needs no token. +- **Rate limiting:** per-IP token bucket on `/api/invoke`. The client IP comes from the actual TCP peer by default (`X-Forwarded-For` is only consulted when `LIVEAGENT_TRUST_PROXY_HEADERS=1`). + +### Headless Command Registry & Generator + +The headless dispatch surface is **generated and verified, not hand-synced**: + +- `scripts/manifest/commands.json` — committed source of truth for the 234 Tauri commands. +- `scripts/build_type_map.py` — derives the Rust type map from `src/*.rs` (`--src/--out`). +- `scripts/gen_adapters.py` — regenerates `crates/agent-gui/src-tauri/src/commands/adapters.rs` from the manifest + type map (`--commands/--types/--out`). +- `scripts/gen_headless.sh` — one-shot pipeline: `build_type_map.py` → `gen_adapters.py`. +- `scripts/verify_headless.py` — asserts `headless.rs` dispatch arms match the manifest **both ways** (no missing, no extra). + +**When you add / remove / rename a command:** + +1. Update `scripts/manifest/commands.json`. +2. Add / adjust the business function in `src/commands/*` (no `#[tauri::command]` needed — it lives only in the generated adapter layer). +3. Run `bash scripts/gen_headless.sh` to regenerate `adapters.rs`. +4. Add / update the matching dispatch arm in `src/headless.rs` (hand-maintained server skeleton — the generator does **not** overwrite it). +5. Run `python3 scripts/verify_headless.py` locally; CI (`gen-verify` job) enforces both steps 3 and 4. + ### Build from Source Expand the Development Guide below for the full set of Make commands. diff --git a/crates/agent-gateway/web/src/components/Markdown.tsx b/crates/agent-gateway/web/src/components/Markdown.tsx index 30aad7a19..31ba05cb8 100644 --- a/crates/agent-gateway/web/src/components/Markdown.tsx +++ b/crates/agent-gateway/web/src/components/Markdown.tsx @@ -2,7 +2,6 @@ import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { type ComponentProps, cloneElement, @@ -37,6 +36,7 @@ import { } from "../lib/markdownCodeBlockPolicy"; import { normalizeLatexDelimiters } from "../lib/normalizeLatexDelimiters"; import { cn } from "../lib/shared/utils"; +import { openUrl } from "../lib/tauriBridge"; import { Check, ChevronDown, ChevronUp, Copy, ExternalLink, X } from "./icons"; import { Button } from "./ui/button"; diff --git a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx index 1d8fbecc2..808df1622 100644 --- a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx @@ -1,4 +1,3 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { type CSSProperties, memo, @@ -22,6 +21,7 @@ import type { SshHostConfig, } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; +import { openUrl } from "../../lib/tauriBridge"; import type { TerminalClient, TerminalSession } from "../../lib/terminal/types"; import type { WorkspaceActivityClient } from "../../lib/workspace-activity/types"; import { X } from "../icons"; diff --git a/crates/agent-gateway/web/src/components/project-tools/git-review/HistoryView.tsx b/crates/agent-gateway/web/src/components/project-tools/git-review/HistoryView.tsx index dff07e2da..6a3158aef 100644 --- a/crates/agent-gateway/web/src/components/project-tools/git-review/HistoryView.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/git-review/HistoryView.tsx @@ -7,7 +7,6 @@ // relative or @tauri-apps/* imports are allowed here. import { useVirtualizer } from "@tanstack/react-virtual"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { type MouseEvent as ReactMouseEvent, type UIEvent as ReactUIEvent, @@ -28,6 +27,7 @@ import { } from "../../../lib/git/gitGraph"; import type { GitCommitFile, GitCommitSummary } from "../../../lib/git/types"; import { cn } from "../../../lib/shared/utils"; +import { openUrl } from "../../../lib/tauriBridge"; import { getFileTypeIcon } from "../../chat/fileTypeIcons"; import { Cloud, diff --git a/crates/agent-gateway/web/src/lib/chat/openChatFileLink.ts b/crates/agent-gateway/web/src/lib/chat/openChatFileLink.ts index 1071b913a..8beccd4d0 100644 --- a/crates/agent-gateway/web/src/lib/chat/openChatFileLink.ts +++ b/crates/agent-gateway/web/src/lib/chat/openChatFileLink.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { ChatFileLink } from "./chatFileLinks"; diff --git a/crates/agent-gateway/web/src/lib/memory/api.ts b/crates/agent-gateway/web/src/lib/memory/api.ts index 91c73e686..bcef905cc 100644 --- a/crates/agent-gateway/web/src/lib/memory/api.ts +++ b/crates/agent-gateway/web/src/lib/memory/api.ts @@ -3,7 +3,7 @@ // shim intercepts every `memory_*` command and forwards it over the websocket // to the connected desktop agent. -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { ApplyDecision, MemoryConfidence, diff --git a/crates/agent-gateway/web/src/lib/providers/proxy.ts b/crates/agent-gateway/web/src/lib/providers/proxy.ts index 5a84c7ec5..74ee38c61 100644 --- a/crates/agent-gateway/web/src/lib/providers/proxy.ts +++ b/crates/agent-gateway/web/src/lib/providers/proxy.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "../../lib/tauriBridge"; import type { ProviderId } from "../settings"; @@ -78,6 +78,14 @@ function normalizeProxyServerInfo(info: ProxyServerInfo): ProxyServerInfo { async function getProxyServerInfo(): Promise { if (!proxyServerInfoPromise) { proxyServerInfoPromise = invoke("proxy_get_server_info") + .then((info) => { + if (!isTauri()) { + // headless(BFF):反代路由挂在主 HTTP 服务上,baseUrl 用页面 origin + // (同机或远程浏览器都正确,无需硬编码主机);token 沿用服务端随机 token。 + return { baseUrl: window.location.origin, token: info.token }; + } + return info; + }) .then(normalizeProxyServerInfo) .catch((error) => { proxyServerInfoPromise = null; diff --git a/crates/agent-gateway/web/src/lib/skills/index.ts b/crates/agent-gateway/web/src/lib/skills/index.ts index 0ef700f8e..2167745ef 100644 --- a/crates/agent-gateway/web/src/lib/skills/index.ts +++ b/crates/agent-gateway/web/src/lib/skills/index.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { sortSkillsForDisplay } from "./builtin"; import type { ClawHubSkillCard } from "./clawHub"; diff --git a/crates/agent-gateway/web/src/lib/tauriBridge.ts b/crates/agent-gateway/web/src/lib/tauriBridge.ts new file mode 100644 index 000000000..71f6672d6 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/tauriBridge.ts @@ -0,0 +1,60 @@ +/** + * Web-side implementation of the tauriBridge interface. + * + * Mirrored GUI components import invoke/listen/openUrl/etc. from `lib/tauriBridge` + * (instead of `@tauri-apps/*` directly). On the desktop side that module lives at + * crates/agent-gui/src/lib/tauriBridge.ts and dispatches to the real Tauri runtime + * or the headless HTTP transport. On the gateway WebUI side this module delegates + * to the existing shims (shims/tauriCore, shims/tauriEvent, shims/tauriOpener), + * which speak the gateway WebSocket protocol — preserving the exact runtime + * behaviour the mirrored components had before (they previously imported + * `@tauri-apps/api/core`, which vite aliases to those shims). + * + * isTauri() always returns false here: the gateway WebUI never runs inside a + * Tauri webview. + */ + +import { invoke as gatewayInvoke } from "../shims/tauriCore"; +import { listen as gatewayListen } from "../shims/tauriEvent"; +import { openUrl as gatewayOpenUrl } from "../shims/tauriOpener"; + +export function isTauri(): boolean { + return false; +} + +export type UnlistenFn = () => void; + +export async function invoke(cmd: string, args?: Record): Promise { + return gatewayInvoke(cmd, args); +} + +export async function listen( + event: string, + handler: (event: { payload: T }) => void, +): Promise { + return gatewayListen(event, handler); +} + +export async function openUrl(url: string): Promise { + return gatewayOpenUrl(url); +} + +export async function revealItemInDir(path: string): Promise { + console.warn("[web] revealItemInDir is not supported; path:", path); +} + +// Desktop-only API passthrough. Mirrored callers guard with isTauri() before +// use, so these never execute in the browser build; the return types only need +// to keep TypeScript happy for code that is unreachable here. +export function getCurrentWindow(): Window { + throw new Error("[web] getCurrentWindow is only available in the Tauri runtime"); +} + +export function getCurrentWebview(): Window { + throw new Error("[web] getCurrentWebview is only available in the Tauri runtime"); +} + +export function homeDir(): Promise { + // The gateway resolves `~` itself on the backend; browsers have no home dir. + return Promise.resolve(""); +} diff --git a/crates/agent-gateway/web/src/pages/mcp-hub/McpImportView.tsx b/crates/agent-gateway/web/src/pages/mcp-hub/McpImportView.tsx index dcc460a0e..0810ddacd 100644 --- a/crates/agent-gateway/web/src/pages/mcp-hub/McpImportView.tsx +++ b/crates/agent-gateway/web/src/pages/mcp-hub/McpImportView.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GlassPanel } from "../../components/hub/HubChrome"; import { @@ -22,6 +21,7 @@ import { scanExternalMcpServers, scanMcpConfigFile, } from "../../lib/skills"; +import { invoke } from "../../lib/tauriBridge"; const EXTERNAL_MCP_TOOL_LABELS: Record = { "claude-code": "Claude Code", diff --git a/crates/agent-gateway/web/src/pages/settings/SshSection.tsx b/crates/agent-gateway/web/src/pages/settings/SshSection.tsx index cba468b02..686376e3c 100644 --- a/crates/agent-gateway/web/src/pages/settings/SshSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SshSection.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { type CSSProperties, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -18,7 +17,6 @@ import { Trash2, Upload, } from "../../components/icons"; - import { Button } from "../../components/ui/button"; import { useConfirmDialog } from "../../components/ui/confirm-dialog"; import { Input } from "../../components/ui/input"; @@ -39,6 +37,7 @@ import { type SshScanResult, scanSshImportCandidates, } from "../../lib/ssh/scan"; +import { invoke } from "../../lib/tauriBridge"; import type { TerminalSession } from "../../lib/terminal/types"; import { ConfirmActionPopover, PromptTag } from "./shared"; import type { SettingsSectionProps } from "./types"; diff --git a/crates/agent-gateway/web/src/pages/settings/providerUtils.ts b/crates/agent-gateway/web/src/pages/settings/providerUtils.ts index 8b8d70296..3d7ac2a62 100644 --- a/crates/agent-gateway/web/src/pages/settings/providerUtils.ts +++ b/crates/agent-gateway/web/src/pages/settings/providerUtils.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { prepareProxyRequest } from "../../lib/providers/proxy"; import { isGatewayWebuiRuntime } from "../../lib/runtimeEnv"; import { @@ -14,6 +13,7 @@ import { type UsageQueryMode, } from "../../lib/settings"; import { normalizeBaseUrl } from "../../lib/settings/normalize"; +import { invoke } from "../../lib/tauriBridge"; const GATEWAY_TOKEN_STORAGE_KEY = "liveagent.gateway.token"; const CODEX_MODELS_SUFFIXES = ["/chat/completions", "/responses", "/response"]; diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index ab3fee027..cf2020768 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -8,6 +8,31 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +# `desktop` is the default build mode: full Tauri runtime (window, tray, +# global shortcuts, updater, file dialogs). +# `--no-default-features` builds the headless runtime (P1.2): same business +# code, no Tauri (axum server + WebSocket bridge, landed in PR-E). Optional +# dependencies are only compiled when the `desktop` feature pulls them in. +default = ["desktop"] +desktop = [ + "dep:tauri", + "dep:tauri-build", + "dep:tauri-plugin-opener", + "dep:tauri-plugin-updater", + "dep:tauri-plugin-mcp-bridge", + "dep:tauri-plugin-global-shortcut", + "dep:tauri-plugin-window-state", + "dep:rfd", + "dep:arboard", + "tauri/tray-icon", + "tauri/image-png", +] +# Headless mode with runtime file fallback (for development). +# Production headless builds should compile with `--no-default-features` and +# embed assets at compile time via build.rs. +runtime-fallback = [] + [lib] # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. @@ -17,19 +42,20 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] serde_json = "1.0.150" -tauri-build = { version = "2.6.3", features = [] } +tauri-build = { version = "2.6.3", features = [], optional = true } prost-build = "0.14.4" [dependencies] -tauri = { version = "2.11.5", features = ["tray-icon", "image-png"] } -tauri-plugin-opener = "2.5.4" +tauri = { version = "2.11.5", optional = true } +tauri-plugin-opener = { version = "2.5.4", optional = true } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" reqwest = { version = "0.13.4", features = ["blocking", "json", "stream", "socks"] } rquickjs = { version = "0.8", features = ["array-buffer", "classes", "bindgen"] } percent-encoding = "2.3.2" -axum = "0.8.9" -tokio = { version = "1.52.3", features = ["macros", "net", "sync", "time", "io-util"] } +axum = { version = "0.8.9", features = ["ws", "multipart"] } +tower-http = { version = "0.6.4", features = ["fs", "cors"] } +tokio = { version = "1.52.3", features = ["macros", "net", "sync", "time", "io-util", "rt-multi-thread"] } tokio-stream = "0.1.18" tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3.32" @@ -41,10 +67,10 @@ regex = "1.12.4" thiserror = "2.0.18" walkdir = "2.5.0" notify = "8.2.0" -rfd = "0.17.2" +rfd = { version = "0.17.2", optional = true } # Text-only clipboard reads (image-data default feature intentionally off). -arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } -tauri-plugin-mcp-bridge = "0.12.0" +arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"], optional = true } +tauri-plugin-mcp-bridge = { version = "0.12.0", optional = true } dirs = "6.0.0" toml = "0.9.11" ignore = "0.4.27" @@ -58,9 +84,9 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] } sha2 = "0.11.0" zstd = "0.13.3" tempfile = "3.27.0" -tauri-plugin-updater = "2.10.1" -tauri-plugin-global-shortcut = "2.3.2" -tauri-plugin-window-state = "2.4.1" +tauri-plugin-updater = { version = "2.10.1", optional = true } +tauri-plugin-global-shortcut = { version = "2.3.2", optional = true } +tauri-plugin-window-state = { version = "2.4.1", optional = true } semver = "1.0.28" quick-xml = "0.41.0" portable-pty = "0.9.0" diff --git a/crates/agent-gui/src-tauri/build.rs b/crates/agent-gui/src-tauri/build.rs index 47e916d9f..3c46c0f79 100644 --- a/crates/agent-gui/src-tauri/build.rs +++ b/crates/agent-gui/src-tauri/build.rs @@ -1,18 +1,39 @@ +//! Build script for agent-gui. +//! +//! Always runs: +//! - Version injection: reads `LIVEAGENT_APP_VERSION` (env override) or the +//! `version` field from `../package.json` and exposes it to the crate as +//! `env!("LIVEAGENT_APP_VERSION")`. +//! - Gateway protobuf compilation: compiles `gateway.proto` + +//! `gateway_ws.proto` (shared with agent-gateway) into +//! `OUT_DIR/liveagent.gateway.v2.rs` via prost-build. +//! +//! Desktop builds (`--features desktop`) additionally run the Tauri build glue. +//! +//! Headless builds (`--no-default-features`) additionally embed the WebUI +//! static assets into `OUT_DIR/embedded_web.rs` so the headless server can +//! serve the UI without a separate `dist` directory at runtime. + +use std::env; +use std::fs; +use std::path::PathBuf; + fn main() { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - let package_json = std::path::Path::new(&manifest_dir) - .join("..") - .join("package.json"); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + // --- Version injection ------------------------------------------------- + let package_json = manifest_dir.join("../package.json"); println!("cargo:rerun-if-changed={}", package_json.display()); println!("cargo:rerun-if-env-changed=LIVEAGENT_APP_VERSION"); - let app_version = std::env::var("LIVEAGENT_APP_VERSION") + let app_version = env::var("LIVEAGENT_APP_VERSION") .ok() .map(|version| version.trim().to_owned()) .filter(|version| !version.is_empty()) .unwrap_or_else(|| { let package_json_text = - std::fs::read_to_string(&package_json).expect("read app package.json for version"); + fs::read_to_string(&package_json).expect("read app package.json for version"); let package_json_value: serde_json::Value = serde_json::from_str(&package_json_text) .expect("parse app package.json for version"); package_json_value @@ -25,11 +46,10 @@ fn main() { }); println!("cargo:rustc-env=LIVEAGENT_APP_VERSION={app_version}"); - // v2 业务消息与 WebSocket 帧壳共用 agent-gateway 目录为 include 根。 - let gateway_root = std::path::Path::new(&manifest_dir) - .join("..") - .join("..") - .join("agent-gateway"); + // --- Gateway protobuf compilation -------------------------------------- + // v2 business messages and the WS frame shell share agent-gateway as the + // proto include root. + let gateway_root = manifest_dir.join("../../agent-gateway"); let proto_v2 = gateway_root.join("proto").join("v2").join("gateway.proto"); let proto_v2_ws = gateway_root .join("proto") @@ -43,16 +63,16 @@ fn main() { .compile_protos(&[proto_v2, proto_v2_ws], &[gateway_root]) .expect("compile gateway protos"); - let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") - && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); - if is_windows_msvc { - let manifest_path = std::path::Path::new( - &std::env::var("OUT_DIR").expect("OUT_DIR for Windows app manifest"), - ) - .join("windows-app-manifest.xml"); - std::fs::write( - &manifest_path, - r#" + // --- Desktop: Tauri build glue ----------------------------------------- + #[cfg(feature = "desktop")] + { + let is_windows_msvc = env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if is_windows_msvc { + let manifest_path = out_dir.join("windows-app-manifest.xml"); + fs::write( + &manifest_path, + r#" "#, - ) - .expect("write Windows app manifest"); - let attributes = tauri_build::Attributes::new() - .windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); - tauri_build::try_build(attributes).expect("run Tauri build script"); - println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); - println!( - "cargo:rustc-link-arg=/MANIFESTINPUT:{}", - manifest_path.display() - ); + ) + .expect("write Windows app manifest"); + let attributes = tauri_build::Attributes::new() + .windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); + tauri_build::try_build(attributes).expect("run Tauri build script"); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display()); + } else { + tauri_build::build(); + } + } + + // --- Headless: embed WebUI static assets ------------------------------- + #[cfg(not(feature = "desktop"))] + embed_webui(&manifest_dir, &out_dir); +} + +/// Embed the WebUI `dist` into `OUT_DIR/embedded_web.rs` for headless builds. +/// +/// Generates: +/// - `EMBEDDED_FILES: LazyLock>` +/// - `fn mime_for_path(path: &str) -> &'static str` +/// +/// dist resolution: `LIVEAGENT_WEB_ROOT` env > `../dist` (relative to +/// src-tauri, matching the runtime-fallback path in headless.rs). +fn embed_webui(manifest_dir: &PathBuf, out_dir: &PathBuf) { + let web_dist = if let Ok(root) = env::var("LIVEAGENT_WEB_ROOT") { + PathBuf::from(root) } else { - tauri_build::build(); + manifest_dir.join("../dist") + }; + + let out_file = out_dir.join("embedded_web.rs"); + + if !web_dist.is_dir() { + eprintln!( + "cargo:warning=WebUI dist not found at {:?}; headless will compile without embedded assets", + web_dist + ); + // Empty stub + fs::write( + &out_file, + "use std::collections::HashMap;\n\ + use std::sync::LazyLock;\n\ + pub static EMBEDDED_FILES: LazyLock> = LazyLock::new(HashMap::new);\n\ + pub fn mime_for_path(_: &str) -> &'static str { \"application/octet-stream\" }\n", + ) + .unwrap(); + return; + } + + // Collect all files + let mut entries: Vec<(String, PathBuf)> = Vec::new(); + collect_files(&web_dist, &web_dist, &mut entries); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut code = String::with_capacity(entries.len() * 100); + code.push_str("// AUTO-GENERATED by build.rs — do not edit.\n"); + code.push_str("use std::collections::HashMap;\n"); + code.push_str("use std::sync::LazyLock;\n\n"); + + // Static byte arrays + for (i, (_, abs)) in entries.iter().enumerate() { + let abs_str = abs.to_str().unwrap(); + code.push_str(&format!("static FILE_{i}: &[u8] = include_bytes!(\"{abs_str}\");\n")); + } + + // LazyLock HashMap + code.push_str("\npub static EMBEDDED_FILES: LazyLock> = LazyLock::new(|| {\n"); + code.push_str(" let mut m = HashMap::new();\n"); + for (i, (rel, _)) in entries.iter().enumerate() { + code.push_str(&format!(" m.insert(\"{rel}\", FILE_{i} as &[u8]);\n")); + } + code.push_str(" m\n});\n\n"); + + // MIME type helper + code.push_str("pub fn mime_for_path(path: &str) -> &'static str {\n"); + code.push_str(" match path.rsplit('.').next() {\n"); + for (ext, mime) in [ + ("html", "text/html; charset=utf-8"), + ("css", "text/css; charset=utf-8"), + ("js", "application/javascript; charset=utf-8"), + ("mjs", "application/javascript; charset=utf-8"), + ("json", "application/json"), + ("svg", "image/svg+xml"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("woff2", "font/woff2"), + ("woff", "font/woff"), + ("ttf", "font/ttf"), + ("ico", "image/x-icon"), + ] { + code.push_str(&format!(" Some(\"{ext}\") => \"{mime}\",\n")); + } + code.push_str(" _ => \"application/octet-stream\",\n"); + code.push_str(" }\n}\n"); + + fs::write(&out_file, &code).unwrap(); + + // Rebuild when dist changes + println!("cargo:rerun-if-changed={}", web_dist.display()); + println!("cargo:rerun-if-env-changed=LIVEAGENT_WEB_ROOT"); +} + +/// Recursively collect files, computing paths relative to `root`. +fn collect_files(root: &PathBuf, dir: &PathBuf, out: &mut Vec<(String, PathBuf)>) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files(root, &path, out); + } else if path.is_file() { + let rel = path.strip_prefix(root).unwrap().to_str().unwrap().to_string(); + out.push((rel, path)); + } + } } } diff --git a/crates/agent-gui/src-tauri/src/app_context.rs b/crates/agent-gui/src-tauri/src/app_context.rs new file mode 100644 index 000000000..aa7896241 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/app_context.rs @@ -0,0 +1,125 @@ +//! 应用级业务状态装配。 +//! +//! `AppContext` 集中管理所有共享状态(store/registry/controller)的创建、 +//! 依赖注入与后台任务启动,**不依赖 tauri** —— desktop(经 tauri `State` +//! 注册)与 headless(axum `AppState`)两个构建共用同一装配逻辑。 + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::commands::app::{CloseWindowBehaviorState, CLOSE_WINDOW_BEHAVIOR_MINIMIZE}; +use crate::commands::git::GitCloneTaskRegistry; +use crate::events::EventEmitter; +use crate::runtime::managed_process::{ManagedProcessNotifier, ManagedProcessRegistry}; +use crate::runtime::sftp::SftpSessionRegistry; +use crate::runtime::terminal::TerminalSessionRegistry; +use crate::services::automation::{AutomationNotifier, AutomationScheduler, AutomationStore}; +use crate::services::gateway::GatewayController; +use crate::services::memory::MemoryStore; +use crate::services::power_activity::PowerActivityManager; +use crate::services::provider_usage::ProviderUsageService; + +/// 应用级共享状态集合。 +/// +/// 字段全部为 `Arc`:desktop 侧每个字段经 `app.manage(Arc::clone(...))` +/// 注册为 tauri `State`;headless 侧整体作为 axum `AppState` 持有。 +pub struct AppContext { + pub automation_store: Arc, + pub automation_scheduler: Arc, + pub memory_store: Arc, + pub provider_usage_service: Arc, + pub power_activity: Arc, + pub managed_process_registry: Arc, + pub terminal_registry: Arc, + pub git_clone_task_registry: Arc, + pub sftp_registry: Arc, + pub allow_exit: Arc, + pub close_window_behavior: Arc, + pub gateway_controller: Arc, +} + +impl AppContext { + /// 装配全部业务状态并启动后台任务。 + /// + /// `event_emitter` 由调用方注入:desktop 经 `shared_emitter(app.handle())`, + /// headless 经 WebSocket 事件广播实现。 + pub fn new(event_emitter: Arc) -> Arc { + let automation_store = Arc::new( + AutomationStore::open().expect("failed to initialize LiveAgent automation store"), + ); + let automation_scheduler = Arc::new(AutomationScheduler::new(Arc::clone( + &automation_store, + ))); + let memory_store = Arc::new( + MemoryStore::open().expect("failed to initialize LiveAgent memory store"), + ); + let provider_usage_service = Arc::new(ProviderUsageService::default()); + let power_activity = Arc::new(PowerActivityManager::default()); + let managed_process_registry = Arc::new(ManagedProcessRegistry::open()); + let terminal_registry = Arc::new(TerminalSessionRegistry::default()); + let git_clone_task_registry = Arc::new(GitCloneTaskRegistry::default()); + let sftp_registry = Arc::new(SftpSessionRegistry::new(Arc::clone(&terminal_registry))); + let allow_exit = Arc::new(AtomicBool::new(false)); + let close_window_behavior = Arc::new(CloseWindowBehaviorState::new( + CLOSE_WINDOW_BEHAVIOR_MINIMIZE, + )); + + // 事件出口:terminal/sftp 会话把内部事件经 emitter 广播给前端。 + terminal_registry.attach_event_emitter(Arc::clone(&event_emitter)); + sftp_registry.attach_event_emitter(Arc::clone(&event_emitter)); + + let gateway_controller = Arc::new(GatewayController::new( + Arc::clone(&event_emitter), + Arc::clone(&automation_store), + Arc::clone(&memory_store), + Arc::clone(&provider_usage_service), + Arc::clone(&terminal_registry), + Arc::clone(&sftp_registry), + Arc::clone(&managed_process_registry), + Arc::clone(&git_clone_task_registry), + )); + + // 进程注册表:自动回收孤儿进程 + 结果回写 gateway。 + managed_process_registry.set_notifier(ManagedProcessNotifier { + event_emitter: Arc::clone(&event_emitter), + gateway: Arc::downgrade(&gateway_controller), + }); + managed_process_registry.spawn_startup_reconcile(); + managed_process_registry.spawn_monitor(); + + // 自动化 store:cron 任务变更经 notifier 转发 gateway/事件。 + automation_store.set_notifier(AutomationNotifier { + event_emitter: Arc::clone(&event_emitter), + gateway: Arc::downgrade(&gateway_controller), + scheduler: Arc::downgrade(&automation_scheduler), + }); + Arc::clone(&automation_scheduler).start(); + + if let Err(error) = gateway_controller.start() { + eprintln!("failed to start remote gateway controller: {error}"); + } + crate::compat::async_runtime::spawn({ + let gateway_controller = Arc::clone(&gateway_controller); + async move { + if let Err(error) = gateway_controller.reload_from_db().await { + eprintln!("failed to load remote gateway settings: {error}"); + } + } + }); + + Arc::new(Self { + automation_store, + automation_scheduler, + memory_store, + provider_usage_service, + power_activity, + managed_process_registry, + terminal_registry, + git_clone_task_registry, + sftp_registry, + allow_exit, + close_window_behavior, + gateway_controller, + }) + } +} diff --git a/crates/agent-gui/src-tauri/src/commands/adapters.rs b/crates/agent-gui/src-tauri/src/commands/adapters.rs new file mode 100644 index 000000000..fe12a99cb --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/adapters.rs @@ -0,0 +1,2072 @@ +// AUTO-GENERATED by scripts/gen_adapters.py — do not edit by hand. +// Regenerate with: scripts/gen_headless.sh (see README "Headless" section). +// Desktop-only thin adapters that re-attach #[tauri::command] to the +// tauri-free business functions in crate::commands. Command names stay +// identical to the pre-refactor ones (P1.1 PR-B). +#![cfg(feature = "desktop")] + +use std::sync::Arc; +use serde_json::Value; + +use crate::commands::app::{CloseWindowBehaviorState, GlobalShortcutBinding, GlobalShortcutFailure, GlobalShortcutRegistry, MacOsTrafficLightMetrics, RuntimePlatformResponse, WindowPinState}; +use crate::commands::chat_file_links::{ChatFileLinkError, ChatFileLinkOpenResponse}; +use crate::commands::chat_history::{ChatHistoryListResponse, ChatHistoryMessageRef, ChatHistorySearchArgs, ChatHistorySearchResponse, ChatHistorySegmentMutationInput, ChatHistoryShareStatus, ChatHistorySummary, ChatHistoryUpsertInput, ChatHistoryWindowRecord, ChatHistoryWorkdirsResponse}; +use crate::commands::fs::{CreateDirResponse, DeleteResponse, EditTextResponse, FsCommandError, FsListDirsResponse, FsRootsResponse, GlobResponse, GrepResponse, ListResponse, MentionListResponse, OpenWorkspacePathResponse, PathStatusResponse, ReadEditableTextResponse, ReadResponse, RenameResponse, WriteTextResponse}; +use crate::commands::git::{GitBranchesResponse, GitCloneTask, GitCloneTaskRegistry, GitCommitDetailsResponse, GitDiffResponse, GitLogResponse, GitOperationResponse, GitRemoteBranchesResponse, GitRepositoryDiscovery, GitRepositoryState}; +use crate::commands::hook::{HookHttpRunResponse, HookScopeRegistry}; +use crate::commands::mcp::{McpCallToolResponse, McpRuntimeManager, McpRuntimeStatus, McpRuntimeTestResponse, McpServerConfig, McpStopServerResponse, McpToolInfo}; +use crate::commands::settings::{CcsProvidersResponse, CherryProvidersResponse, SettingsLoadResponse, SshKnownHostResetResponse, SshPatchApplyResponse}; +use crate::commands::shell::{ShellCancelResponse}; +use crate::commands::subagent_store::{SubagentIdentityListInput, SubagentIdentityRecord, SubagentIdentityUpsertInput, SubagentMessageAppendInput, SubagentMessageListInput, SubagentMessageRecord, SubagentPruneResult, SubagentRunListInput, SubagentRunLoadInput, SubagentRunPruneInput, SubagentRunRecord, SubagentRunSaveInput, SubagentRunStateRecord}; +use crate::commands::subagent_worktree::{SubagentWorktreeApplyInput, SubagentWorktreeApplyResponse, SubagentWorktreeCleanupInput, SubagentWorktreeCleanupItem, SubagentWorktreeCreateInput, SubagentWorktreeCreateResponse, SubagentWorktreeStatusInput, SubagentWorktreeStatusResponse}; +use crate::commands::system::{SystemCreateProjectFolderResponse, SystemPastedTextInput, SystemPickReadableFilesResponse, SystemUploadedImagePreviewResponse, SystemUploadedNativeAttachmentResponse, SystemUploadedReadableFileInput}; +use crate::commands::update::{AppUpdateCheckResponse}; +use crate::runtime::managed_process::{ManagedProcessLogResponse, ManagedProcessRegistry, ManagedProcessSnapshot, ManagedProcessStartResponse, ManagedProcessStatusResponse, ManagedProcessStopResponse}; +use crate::runtime::sftp::{SftpActionResponse, SftpListResponse, SftpReadTextResponse, SftpSessionRegistry, SftpStatResponse, SftpTransferResponse}; +use crate::runtime::shell_runner::{ShellRunRegistry, ShellRunResponse}; +use crate::runtime::task_runner::{HttpRequestInput}; +use crate::runtime::terminal::{SshLocalForwardActionResponse, SshLocalForwardListResponse, SshTerminalTabsSnapshot, TerminalListResponse, TerminalReadTailResponse, TerminalSessionRecord, TerminalSessionRegistry, TerminalShellOptionsResponse, TerminalSnapshotResponse, TerminalSshCreateResponse, TerminalSshExecResponse, TerminalSshLatencyResponse, TerminalStreamSnapshotResponse}; +use crate::services::automation::{AutomationApplyInput, AutomationSnapshot, CompletePromptRunInput, CronApplyResponse, CronRunNowResponse, CronRunRecord, HooksApplyResponse, PromptCompletionResponse, PromptRunRequest}; +use crate::services::automation::scheduler::{AutomationScheduler}; +use crate::services::automation::store::{AutomationStore}; +use crate::services::gateway::{GatewayChatClaimedRequest, GatewayChatQueueEventInput, GatewayChatQueueResponseInput, GatewayController, GatewayStatusSnapshot}; +use crate::services::gateway::chat_ingress::{GatewayChatCheckpointCommitResult, GatewayChatCheckpointInput, GatewayChatIngressAcceptResult, GatewayChatIngressBatchInput}; +use crate::services::memory::{MemoryAcceptArgs, MemoryBatchArgs, MemoryBatchResponse, MemoryDeleteArgs, MemoryDeleteProjectArgs, MemoryDeleteProjectResponse, MemoryListArgs, MemoryListResponse, MemoryMutationResponse, MemoryOrganizeDueClaimArgs, MemoryOrganizeDueClaimResponse, MemoryOrganizeRun, MemoryOrganizeRunClearHistoryResponse, MemoryOrganizeRunCreateArgs, MemoryOrganizeRunCreateResponse, MemoryOrganizeRunListArgs, MemoryOrganizeRunListResponse, MemoryOrganizeRunReadArgs, MemoryOrganizeRunUpdateArgs, MemoryOverviewResponse, MemoryPathsInfo, MemoryQuotaSummaryArgs, MemoryQuotaSummaryResponse, MemoryReadArgs, MemoryReadResponse, MemoryRecentRejectionsArgs, MemoryRecentRejectionsResponse, MemorySearchArgs, MemorySearchResponse, MemoryStore, MemoryUpdateArgs, MemoryWriteArgs}; +use crate::services::power_activity::{PowerActivityManager}; +use crate::services::provider_usage::{ProviderUsageResult, ProviderUsageService}; +use crate::services::proxy::{ProxyServerInfo, ProxyServerState}; +use crate::services::skills::{SystemListSkillFilesResponse, SystemManageSkillResponse, SystemReadSkillMetadataResponse, SystemReadSkillTextResponse}; +use crate::services::tray::{TrayMenuHandles, TrayMenuModel}; +use crate::services::tunnel::{GatewayTunnelCreateInput, GatewayTunnelUpdateInput, TunnelStatePayload}; + +use std::sync::atomic::AtomicBool; +use std::collections::HashMap; + +// ===== app ===== +#[tauri::command] +pub fn app_window_pinned( + pin_state: tauri::State<'_, Arc>, +) -> bool { + crate::commands::app::app_window_pinned(pin_state.inner()) +} + +#[tauri::command] +pub fn app_toggle_window_pin( + app: tauri::AppHandle, +) { + crate::commands::app::app_toggle_window_pin(app) +} + +#[tauri::command] +pub fn app_set_global_shortcuts( + app: tauri::AppHandle, + bindings: Vec, + registry: tauri::State<'_, Arc>, +) -> Result, String> { + crate::commands::app::app_set_global_shortcuts(app, bindings, registry.inner()) +} + +#[tauri::command] +pub fn app_runtime_platform( +) -> RuntimePlatformResponse { + crate::commands::app::app_runtime_platform() +} + +#[tauri::command] +pub fn app_set_close_window_behavior( + behavior: String, + close_window_behavior: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::app::app_set_close_window_behavior(behavior, close_window_behavior.inner()) +} + +#[tauri::command] +pub fn app_confirmed_exit( + app: tauri::AppHandle, + allow_exit: tauri::State<'_, Arc>, + terminal_registry: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::app::app_confirmed_exit(app, allow_exit.inner(), terminal_registry.inner()) +} + +#[tauri::command] +pub async fn app_macos_traffic_light_metrics( + window: tauri::Window, +) -> Result, String> { + crate::commands::app::app_macos_traffic_light_metrics(window).await +} + +// ===== tray ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn app_tray_menu_sync( + app: tauri::AppHandle, + model: TrayMenuModel, + handles: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::tray::app_tray_menu_sync(app, model, handles.inner()).await +} + +// ===== update ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn app_update_check( + app: tauri::AppHandle, + include_prerelease: bool, +) -> Result { + crate::commands::update::app_update_check(app, include_prerelease).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn app_update_install( + app: tauri::AppHandle, + include_prerelease: bool, +) -> Result { + crate::commands::update::app_update_install(app, include_prerelease).await +} + +#[tauri::command] +pub fn app_restart( + app: tauri::AppHandle, +) -> Result<(), String> { + crate::commands::update::app_restart(app) +} + +// ===== system ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn system_pick_folder( + initial_workdir: Option, +) -> Result, String> { + crate::commands::system::system_pick_folder(initial_workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_pick_file( + initial_workdir: Option, + filter_name: Option, + extensions: Option>, +) -> Result, String> { + crate::commands::system::system_pick_file(initial_workdir, filter_name, extensions).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_create_project_folder( + parent: String, + name: String, +) -> Result { + crate::commands::system::system_create_project_folder(parent, name).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_pick_readable_files( + workdir: String, + max_files: Option, +) -> Result { + crate::commands::system::system_pick_readable_files(workdir, max_files).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_import_readable_file_paths( + workdir: String, + paths: Vec, + max_files: Option, +) -> Result { + crate::commands::system::system_import_readable_file_paths(workdir, paths, max_files).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_import_uploaded_readable_files( + workdir: String, + files: Vec, + max_files: Option, +) -> Result { + crate::commands::system::system_import_uploaded_readable_files(workdir, files, max_files).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_import_pasted_texts( + workdir: String, + texts: Vec, +) -> Result { + crate::commands::system::system_import_pasted_texts(workdir, texts).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_read_uploaded_image_preview( + workdir: String, + absolute_path: String, +) -> Result { + crate::commands::system::system_read_uploaded_image_preview(workdir, absolute_path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_read_uploaded_native_attachment( + workdir: String, + absolute_path: Option, + kind: Option, +) -> Result { + crate::commands::system::system_read_uploaded_native_attachment(workdir, absolute_path, kind).await +} + +#[tauri::command] +pub async fn system_list_skill_files( +) -> Result { + crate::commands::system::system_list_skill_files().await +} + +#[tauri::command] +pub async fn system_ensure_builtin_skills( +) -> Result, String> { + crate::commands::system::system_ensure_builtin_skills().await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_manage_skill( + payload: Value, +) -> Result { + crate::commands::system::system_manage_skill(payload).await +} + +#[tauri::command] +pub async fn system_read_skill_text( + path: String, + offset: Option, + length: Option, +) -> Result { + crate::commands::system::system_read_skill_text(path, offset, length).await +} + +#[tauri::command] +pub async fn system_read_skill_metadata( + path: String, +) -> Result { + crate::commands::system::system_read_skill_metadata(path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_append_debug_jsonl( + conversation_id: String, + entry: Value, +) -> Result<(), String> { + crate::commands::system::system_append_debug_jsonl(conversation_id, entry).await +} + +#[tauri::command] +pub async fn system_clipboard_read_text( +) -> Result { + crate::commands::system::system_clipboard_read_text().await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn system_begin_power_activity( + activity_id: String, + reason: String, + ttl_ms: Option, + power_activity: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::system::system_begin_power_activity(activity_id, reason, ttl_ms, power_activity.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn system_end_power_activity( + activity_id: String, + power_activity: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::system::system_end_power_activity(activity_id, power_activity.inner()) +} + +// ===== cron ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn cron_validate_expression( + expression: String, +) -> Result<(), String> { + crate::commands::cron::cron_validate_expression(expression).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_snapshot( + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_snapshot(store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_cron_apply( + input: AutomationApplyInput, + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_cron_apply(input, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_hooks_apply( + input: AutomationApplyInput, + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_hooks_apply(input, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_list_runs( + task_id: String, + limit: Option, + store: tauri::State<'_, Arc>, +) -> Result, String> { + crate::commands::cron::automation_list_runs(task_id, limit, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_clear_runs( + task_id: String, + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_clear_runs(task_id, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_run_cron_now( + task_id: String, + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_run_cron_now(task_id, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_claim_prompt_runs( + store: tauri::State<'_, Arc>, +) -> Result, String> { + crate::commands::cron::automation_claim_prompt_runs(store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_release_prompt_run( + execution_id: String, + store: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::cron::automation_release_prompt_run(execution_id, store.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn automation_complete_prompt_run( + input: CompletePromptRunInput, + store: tauri::State<'_, Arc>, +) -> Result { + crate::commands::cron::automation_complete_prompt_run(input, store.inner()).await +} + +// ===== hook ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn hook_run_script( + workdir: Option, + script: String, + timeout_ms: Option, + scope_id: Option, + context: Option>, + registry: tauri::State<'_, Arc>, +) -> Result { + crate::commands::hook::hook_run_script(workdir, script, timeout_ms, scope_id, context, registry.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn hook_run_http_requests( + requests: Vec, + scope_id: Option, + registry: tauri::State<'_, Arc>, +) -> Result { + crate::commands::hook::hook_run_http_requests(requests, scope_id, registry.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn hook_cancel_scope( + scope_id: String, + registry: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::hook::hook_cancel_scope(scope_id, registry.inner()).await +} + +// ===== settings ===== +#[tauri::command] +pub async fn settings_list_ccswitch_providers( +) -> Result { + crate::commands::settings::settings_list_ccswitch_providers().await +} + +#[tauri::command] +pub async fn settings_list_cherry_studio_providers( +) -> Result { + crate::commands::settings::settings_list_cherry_studio_providers().await +} + +#[tauri::command] +pub async fn settings_list_cherry_studio_providers_from_path( + data_path: String, +) -> Result { + crate::commands::settings::settings_list_cherry_studio_providers_from_path(data_path).await +} + +#[tauri::command] +pub async fn settings_load_all( +) -> Result { + crate::commands::settings::settings_load_all().await +} + +#[tauri::command] +pub async fn settings_save_providers( + payload: Value, +) -> Result<(), String> { + crate::commands::settings::settings_save_providers(payload).await +} + +#[tauri::command] +pub async fn settings_save_system( + payload: Value, + automation_scheduler: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::settings::settings_save_system(payload, automation_scheduler.inner()).await +} + +#[tauri::command] +pub async fn settings_save_mcp( + payload: Value, +) -> Result<(), String> { + crate::commands::settings::settings_save_mcp(payload).await +} + +#[tauri::command] +pub async fn settings_save_remote( + payload: Value, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::settings::settings_save_remote(payload, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn settings_save_memory( + payload: Value, +) -> Result<(), String> { + crate::commands::settings::settings_save_memory(payload).await +} + +#[tauri::command] +pub async fn settings_save_agents( + payload: Value, +) -> Result<(), String> { + crate::commands::settings::settings_save_agents(payload).await +} + +#[tauri::command] +pub async fn settings_save_ssh( + payload: Value, +) -> Result<(), String> { + crate::commands::settings::settings_save_ssh(payload).await +} + +#[tauri::command] +pub async fn settings_apply_ssh_patch( + payload: Value, +) -> Result { + crate::commands::settings::settings_apply_ssh_patch(payload).await +} + +#[tauri::command] +pub async fn settings_reset_ssh_known_host( + host: String, + port: u16, +) -> Result { + crate::commands::settings::settings_reset_ssh_known_host(host, port).await +} + +// ===== subagent_store ===== +#[tauri::command] +pub async fn subagent_identity_upsert( + input: SubagentIdentityUpsertInput, +) -> Result { + crate::commands::subagent_store::subagent_identity_upsert(input).await +} + +#[tauri::command] +pub async fn subagent_identity_list( + input: SubagentIdentityListInput, +) -> Result, String> { + crate::commands::subagent_store::subagent_identity_list(input).await +} + +#[tauri::command] +pub async fn subagent_run_save( + input: SubagentRunSaveInput, +) -> Result<(), String> { + crate::commands::subagent_store::subagent_run_save(input).await +} + +#[tauri::command] +pub async fn subagent_run_list( + input: SubagentRunListInput, +) -> Result, String> { + crate::commands::subagent_store::subagent_run_list(input).await +} + +#[tauri::command] +pub async fn subagent_run_load( + input: SubagentRunLoadInput, +) -> Result, String> { + crate::commands::subagent_store::subagent_run_load(input).await +} + +#[tauri::command] +pub async fn subagent_run_prune( + input: SubagentRunPruneInput, +) -> Result { + crate::commands::subagent_store::subagent_run_prune(input).await +} + +#[tauri::command] +pub async fn subagent_message_append( + input: SubagentMessageAppendInput, +) -> Result { + crate::commands::subagent_store::subagent_message_append(input).await +} + +#[tauri::command] +pub async fn subagent_message_list( + input: SubagentMessageListInput, +) -> Result, String> { + crate::commands::subagent_store::subagent_message_list(input).await +} + +// ===== chat_history ===== +#[tauri::command] +pub async fn chat_history_branch( + id: String, + base_message_ref: ChatHistoryMessageRef, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_branch(id, base_message_ref, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_list( + page: i64, + page_size: i64, + cwd: Option, + cwd_empty: Option, +) -> Result { + crate::commands::chat_history::chat_history_list(page, page_size, cwd, cwd_empty).await +} + +#[tauri::command] +pub async fn chat_history_workdirs( +) -> Result { + crate::commands::chat_history::chat_history_workdirs().await +} + +#[tauri::command] +pub async fn chat_history_shared_list( + page: i64, + page_size: i64, +) -> Result { + crate::commands::chat_history::chat_history_shared_list(page, page_size).await +} + +#[tauri::command] +pub async fn chat_history_search( + args: ChatHistorySearchArgs, +) -> Result { + crate::commands::chat_history::chat_history_search(args).await +} + +#[tauri::command] +pub async fn chat_history_get_window( + id: String, + max_messages: i64, + before_offset: Option, + expected_revision: Option, + include_active_segment: bool, +) -> Result { + crate::commands::chat_history::chat_history_get_window(id, max_messages, before_offset, expected_revision, include_active_segment).await +} + +#[tauri::command] +pub async fn chat_history_upsert( + input: ChatHistoryUpsertInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_upsert(input, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_upsert_active_segment( + input: ChatHistorySegmentMutationInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_upsert_active_segment(input, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_append_segment( + input: ChatHistorySegmentMutationInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_append_segment(input, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_rename( + id: String, + title: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_rename(id, title, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_set_pinned( + id: String, + is_pinned: bool, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_set_pinned(id, is_pinned, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_set_model( + id: String, + selected_model_json: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_set_model(id, selected_model_json, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_share_get( + id: String, +) -> Result { + crate::commands::chat_history::chat_history_share_get(id).await +} + +#[tauri::command] +pub async fn chat_history_share_set( + id: String, + enabled: bool, + redact_tool_content: Option, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_share_set(id, enabled, redact_tool_content, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_delete( + id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::chat_history::chat_history_delete(id, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn chat_history_replace_from_message( + id: String, + base_message_ref: ChatHistoryMessageRef, + replacement_message: Value, + max_messages: i64, + expected_revision: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::chat_history::chat_history_replace_from_message(id, base_message_ref, replacement_message, max_messages, expected_revision, gateway_controller.inner()).await +} + +// ===== gateway ===== +#[tauri::command] +pub async fn provider_usage_query( + provider_id: String, + refresh: bool, + provider_usage_service: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::provider_usage_query(provider_id, refresh, provider_usage_service.inner()).await +} + +#[tauri::command] +pub async fn provider_usage_test( + provider_id: String, + config_json: String, + provider_usage_service: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::provider_usage_test(provider_id, config_json, provider_usage_service.inner()).await +} + +#[tauri::command] +pub async fn gateway_connect( + payload: Option, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_connect(payload, gateway_controller.inner()).await +} + +#[tauri::command] +pub fn gateway_disconnect( + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_disconnect(gateway_controller.inner()) +} + +#[tauri::command] +pub fn gateway_status( + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::gateway_status(gateway_controller.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn gateway_nudge_connection( + reason: Option, + force_reconnect: Option, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::gateway_nudge_connection(reason, force_reconnect, gateway_controller.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_send_chat_ingress_batch( + input: GatewayChatIngressBatchInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::gateway_send_chat_ingress_batch(input, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_commit_chat_checkpoint( + input: GatewayChatCheckpointInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::gateway_commit_chat_checkpoint(input, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_claim_next( + worker_id: String, + lease_ms: Option, + gateway_controller: tauri::State<'_, Arc>, +) -> Result, String> { + crate::commands::gateway::gateway_chat_claim_next(worker_id, lease_ms, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_mark_started( + request_id: String, + conversation_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_mark_started(request_id, conversation_id, worker_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_mark_local_started( + request_id: String, + conversation_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_mark_local_started(request_id, conversation_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_mark_local_cancelled( + request_id: String, + conversation_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_mark_local_cancelled(request_id, conversation_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_mark_queued_in_gui( + request_id: String, + conversation_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_mark_queued_in_gui(request_id, conversation_id, worker_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_complete( + request_id: String, + conversation_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_complete(request_id, conversation_id, worker_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_fail( + request_id: String, + conversation_id: Option, + error_code: String, + message: String, + terminal: bool, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_fail(request_id, conversation_id, error_code, message, terminal, worker_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_cancel_request( + request_id: String, + conversation_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_cancel_request(request_id, conversation_id, worker_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn gateway_chat_heartbeat( + request_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_heartbeat(request_id, worker_id, gateway_controller.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_chat_runtime_heartbeat( + worker_id: String, + state: String, + visible: bool, + active_run_count: u32, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_runtime_heartbeat(worker_id, state, visible, active_run_count, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn gateway_chat_release_lease( + request_id: String, + worker_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_release_lease(request_id, worker_id, gateway_controller.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn gateway_chat_queue_respond( + input: GatewayChatQueueResponseInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_chat_queue_respond(input, gateway_controller.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_publish_chat_queue_event( + input: GatewayChatQueueEventInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_publish_chat_queue_event(input, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn gateway_publish_settings_sync( + payload: Value, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_publish_settings_sync(payload, gateway_controller.inner()).await +} + +#[tauri::command] +pub fn gateway_tunnel_state( + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + crate::commands::gateway::gateway_tunnel_state(gateway_controller.inner()) +} + +#[tauri::command] +pub async fn gateway_tunnel_create( + input: GatewayTunnelCreateInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_tunnel_create(input, gateway_controller.inner()).await +} + +#[tauri::command] +pub async fn gateway_tunnel_update( + input: GatewayTunnelUpdateInput, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_tunnel_update(input, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_tunnel_close( + tunnel_id: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_tunnel_close(tunnel_id, gateway_controller.inner()).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn gateway_tunnel_check( + tunnel_id: Option, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::gateway_tunnel_check(tunnel_id, gateway_controller.inner()).await +} + +#[tauri::command] +pub fn workspace_watch_set( + workdirs: Vec, + gateway_controller: tauri::State<'_, Arc>, +) -> Result<(), String> { + crate::commands::gateway::workspace_watch_set(workdirs, gateway_controller.inner()) +} + +// ===== mcp ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_list_tools( + state: tauri::State<'_, Arc>, + servers: Vec, +) -> Result, String> { + crate::commands::mcp::mcp_list_tools(state.inner(), servers).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_call_tool( + state: tauri::State<'_, Arc>, + run_registry: tauri::State<'_, Arc>, + server_id: String, + tool_name: String, + arguments: Value, + run_id: Option, +) -> Result { + crate::commands::mcp::mcp_call_tool(state.inner(), run_registry.inner(), server_id, tool_name, arguments, run_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_runtime_status( + state: tauri::State<'_, Arc>, + server_id: String, +) -> Result { + crate::commands::mcp::mcp_runtime_status(state.inner(), server_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_stop_server( + state: tauri::State<'_, Arc>, + server_id: String, +) -> Result { + crate::commands::mcp::mcp_stop_server(state.inner(), server_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_test_server( + state: tauri::State<'_, Arc>, + server: McpServerConfig, + include_schema: Option, + persist: Option, +) -> Result { + crate::commands::mcp::mcp_test_server(state.inner(), server, include_schema, persist).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn mcp_restart_server( + state: tauri::State<'_, Arc>, + server: McpServerConfig, + include_schema: Option, + persist: Option, +) -> Result { + crate::commands::mcp::mcp_restart_server(state.inner(), server, include_schema, persist).await +} + +// ===== memory ===== +#[tauri::command] +pub async fn memory_list( + state: tauri::State<'_, Arc>, + args: MemoryListArgs, +) -> Result { + crate::commands::memory::memory_list(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_read( + state: tauri::State<'_, Arc>, + args: MemoryReadArgs, +) -> Result { + crate::commands::memory::memory_read(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_search( + state: tauri::State<'_, Arc>, + args: MemorySearchArgs, +) -> Result { + crate::commands::memory::memory_search(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_write( + state: tauri::State<'_, Arc>, + args: MemoryWriteArgs, +) -> Result { + crate::commands::memory::memory_write(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_update( + state: tauri::State<'_, Arc>, + args: MemoryUpdateArgs, +) -> Result { + crate::commands::memory::memory_update(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_delete( + state: tauri::State<'_, Arc>, + args: MemoryDeleteArgs, +) -> Result { + crate::commands::memory::memory_delete(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_delete_project( + state: tauri::State<'_, Arc>, + args: MemoryDeleteProjectArgs, +) -> Result { + crate::commands::memory::memory_delete_project(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_accept( + state: tauri::State<'_, Arc>, + args: MemoryAcceptArgs, +) -> Result { + crate::commands::memory::memory_accept(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_apply_batch( + state: tauri::State<'_, Arc>, + args: MemoryBatchArgs, +) -> Result { + crate::commands::memory::memory_apply_batch(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_run_create( + state: tauri::State<'_, Arc>, + args: MemoryOrganizeRunCreateArgs, +) -> Result { + crate::commands::memory::memory_organize_run_create(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_run_update( + state: tauri::State<'_, Arc>, + args: MemoryOrganizeRunUpdateArgs, +) -> Result, String> { + crate::commands::memory::memory_organize_run_update(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_run_list( + state: tauri::State<'_, Arc>, + args: Option, +) -> Result { + crate::commands::memory::memory_organize_run_list(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_run_read( + state: tauri::State<'_, Arc>, + args: MemoryOrganizeRunReadArgs, +) -> Result, String> { + crate::commands::memory::memory_organize_run_read(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_run_clear_history( + state: tauri::State<'_, Arc>, +) -> Result { + crate::commands::memory::memory_organize_run_clear_history(state.inner()).await +} + +#[tauri::command] +pub async fn memory_organize_due_claim( + state: tauri::State<'_, Arc>, + args: MemoryOrganizeDueClaimArgs, +) -> Result { + crate::commands::memory::memory_organize_due_claim(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_organize_due_complete( + state: tauri::State<'_, Arc>, + args: MemoryOrganizeRunUpdateArgs, +) -> Result, String> { + crate::commands::memory::memory_organize_due_complete(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_index_overview( + state: tauri::State<'_, Arc>, + workdir: Option, +) -> Result { + crate::commands::memory::memory_index_overview(state.inner(), workdir).await +} + +#[tauri::command] +pub async fn memory_paths_info( + state: tauri::State<'_, Arc>, +) -> Result { + crate::commands::memory::memory_paths_info(state.inner()).await +} + +#[tauri::command] +pub async fn memory_recent_rejections( + state: tauri::State<'_, Arc>, + args: Option, +) -> Result { + crate::commands::memory::memory_recent_rejections(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_today_local_date( + state: tauri::State<'_, Arc>, + rollover_hour: Option, +) -> Result { + crate::commands::memory::memory_today_local_date(state.inner(), rollover_hour).await +} + +#[tauri::command] +pub async fn memory_today_daily( + state: tauri::State<'_, Arc>, + rollover_hour: Option, +) -> Result, String> { + crate::commands::memory::memory_today_daily(state.inner(), rollover_hour).await +} + +#[tauri::command] +pub async fn memory_quota_summary( + state: tauri::State<'_, Arc>, + args: Option, +) -> Result { + crate::commands::memory::memory_quota_summary(state.inner(), args).await +} + +#[tauri::command] +pub async fn memory_wipe_all( + state: tauri::State<'_, Arc>, +) -> Result { + crate::commands::memory::memory_wipe_all(state.inner()).await +} + +// ===== process ===== +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_start( + registry: tauri::State<'_, Arc>, + workdir: String, + command: String, + cwd: Option, + label: Option, + isolated: Option, +) -> Result { + crate::commands::process::managed_process_start(registry.inner(), workdir, command, cwd, label, isolated) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_status( + registry: tauri::State<'_, Arc>, + process_id: Option, +) -> Result { + crate::commands::process::managed_process_status(registry.inner(), process_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_stop( + registry: tauri::State<'_, Arc>, + process_id: String, +) -> Result { + crate::commands::process::managed_process_stop(registry.inner(), process_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_read_log( + registry: tauri::State<'_, Arc>, + process_id: String, + max_bytes: Option, +) -> Result { + crate::commands::process::managed_process_read_log(registry.inner(), process_id, max_bytes) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_snapshot( + registry: tauri::State<'_, Arc>, +) -> Result { + crate::commands::process::managed_process_snapshot(registry.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn managed_process_clear( + registry: tauri::State<'_, Arc>, + process_id: Option, +) -> Result { + crate::commands::process::managed_process_clear(registry.inner(), process_id) +} + +// ===== sftp ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_list( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + side: String, + path: Option, +) -> Result { + crate::commands::sftp::sftp_list(registry.inner(), session_id, project_path_key, workdir, side, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_stat( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + side: String, + path: Option, +) -> Result { + crate::commands::sftp::sftp_stat(registry.inner(), session_id, project_path_key, workdir, side, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_read_text( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + path: String, + offset: Option, + max_bytes: Option, +) -> Result { + crate::commands::sftp::sftp_read_text(registry.inner(), session_id, project_path_key, path, offset, max_bytes).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_write_text( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + path: String, + content: String, + overwrite: Option, + create_parent_dirs: Option, +) -> Result { + crate::commands::sftp::sftp_write_text(registry.inner(), session_id, project_path_key, path, content, overwrite, create_parent_dirs).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_mkdir( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + side: String, + path: String, +) -> Result { + crate::commands::sftp::sftp_mkdir(registry.inner(), session_id, project_path_key, workdir, side, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_rename( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + side: String, + from_path: String, + to_path: String, +) -> Result { + crate::commands::sftp::sftp_rename(registry.inner(), session_id, project_path_key, workdir, side, from_path, to_path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_delete( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + side: String, + path: String, + recursive: Option, +) -> Result { + crate::commands::sftp::sftp_delete(registry.inner(), session_id, project_path_key, workdir, side, path, recursive).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn sftp_transfer( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + workdir: String, + direction: String, + source_path: String, + target_path: String, + recursive: Option, + overwrite: Option, +) -> Result { + crate::commands::sftp::sftp_transfer(registry.inner(), session_id, project_path_key, workdir, direction, source_path, target_path, recursive, overwrite).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn sftp_cancel_transfer( + registry: tauri::State<'_, Arc>, + session_id: String, + transfer_id: String, +) -> Result<(), String> { + crate::commands::sftp::sftp_cancel_transfer(registry.inner(), session_id, transfer_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn sftp_transfer_status( + registry: tauri::State<'_, Arc>, + session_id: String, + transfer_id: String, +) -> Result { + crate::commands::sftp::sftp_transfer_status(registry.inner(), session_id, transfer_id) +} + +// ===== terminal ===== +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_shell_options( +) -> TerminalShellOptionsResponse { + crate::commands::terminal::terminal_shell_options() +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_list( + registry: tauri::State<'_, Arc>, + project_path_key: Option, +) -> TerminalListResponse { + crate::commands::terminal::terminal_list(registry.inner(), project_path_key) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_create( + registry: tauri::State<'_, Arc>, + cwd: String, + project_path_key: Option, + shell: Option, + title: Option, + cols: Option, + rows: Option, +) -> Result { + crate::commands::terminal::terminal_create(registry.inner(), cwd, project_path_key, shell, title, cols, rows) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_create_ssh( + registry: tauri::State<'_, Arc>, + cwd: String, + project_path_key: Option, + ssh_host_id: String, + title: Option, + cols: Option, + rows: Option, + sftp_enabled: Option, +) -> Result { + crate::commands::terminal::terminal_create_ssh(registry.inner(), cwd, project_path_key, ssh_host_id, title, cols, rows, sftp_enabled).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_answer_ssh_prompt( + registry: tauri::State<'_, Arc>, + prompt_id: String, + prompt_answer: Option, + trust_host_key: Option, +) -> Result { + crate::commands::terminal::terminal_answer_ssh_prompt(registry.inner(), prompt_id, prompt_answer, trust_host_key).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_cancel_ssh_prompt( + registry: tauri::State<'_, Arc>, + prompt_id: String, +) -> Result<(), String> { + crate::commands::terminal::terminal_cancel_ssh_prompt(registry.inner(), prompt_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_reconnect( + registry: tauri::State<'_, Arc>, + session_id: String, +) -> Result { + crate::commands::terminal::terminal_ssh_reconnect(registry.inner(), session_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_latency( + registry: tauri::State<'_, Arc>, + session_id: String, +) -> Result { + crate::commands::terminal::terminal_ssh_latency(registry.inner(), session_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_exec( + registry: tauri::State<'_, Arc>, + run_registry: tauri::State<'_, Arc>, + session_id: String, + command: String, + cwd: Option, + timeout_ms: Option, + max_bytes: Option, + run_id: Option, +) -> Result { + crate::commands::terminal::terminal_ssh_exec(registry.inner(), run_registry.inner(), session_id, command, cwd, timeout_ms, max_bytes, run_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_local_forward_start( + registry: tauri::State<'_, Arc>, + session_id: String, + project_path_key: Option, + remote_host: String, + remote_port: u32, + local_port: Option, +) -> Result { + crate::commands::terminal::terminal_ssh_local_forward_start(registry.inner(), session_id, project_path_key, remote_host, remote_port, local_port).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_ssh_local_forward_list( + registry: tauri::State<'_, Arc>, + session_id: Option, + project_path_key: Option, +) -> Result { + crate::commands::terminal::terminal_ssh_local_forward_list(registry.inner(), session_id, project_path_key) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_local_forward_stop( + registry: tauri::State<'_, Arc>, + forward_id: String, + session_id: Option, +) -> Result { + crate::commands::terminal::terminal_ssh_local_forward_stop(registry.inner(), forward_id, session_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn terminal_ssh_local_forward_check_port( + local_port: u32, +) -> Result { + crate::commands::terminal::terminal_ssh_local_forward_check_port(local_port).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn ssh_terminal_tabs_list( + registry: tauri::State<'_, Arc>, + project_path_key: String, +) -> Result { + crate::commands::terminal::ssh_terminal_tabs_list(registry.inner(), project_path_key) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn ssh_terminal_tab_open( + registry: tauri::State<'_, Arc>, + session_id: String, + kind: String, +) -> Result { + crate::commands::terminal::ssh_terminal_tab_open(registry.inner(), session_id, kind) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn ssh_terminal_tab_close( + registry: tauri::State<'_, Arc>, + tab_id: String, +) -> Result { + crate::commands::terminal::ssh_terminal_tab_close(registry.inner(), tab_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_stream_attach( + registry: tauri::State<'_, Arc>, + session_id: String, + max_bytes: Option, +) -> Result { + crate::commands::terminal::terminal_stream_attach(registry.inner(), session_id, max_bytes) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_stream_input( + registry: tauri::State<'_, Arc>, + session_id: String, + bytes: Vec, +) -> Result<(), String> { + crate::commands::terminal::terminal_stream_input(registry.inner(), session_id, bytes) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_stream_resize( + registry: tauri::State<'_, Arc>, + session_id: String, + cols: u16, + rows: u16, +) -> Result<(), String> { + crate::commands::terminal::terminal_stream_resize(registry.inner(), session_id, cols, rows) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_rename( + registry: tauri::State<'_, Arc>, + session_id: String, + title: String, +) -> Result { + crate::commands::terminal::terminal_rename(registry.inner(), session_id, title) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_close( + registry: tauri::State<'_, Arc>, + sftp_registry: tauri::State<'_, Arc>, + session_id: String, +) -> Result { + crate::commands::terminal::terminal_close(registry.inner(), sftp_registry.inner(), session_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_close_project( + registry: tauri::State<'_, Arc>, + sftp_registry: tauri::State<'_, Arc>, + project_path_key: String, +) -> Result { + crate::commands::terminal::terminal_close_project(registry.inner(), sftp_registry.inner(), project_path_key) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn terminal_read_tail( + registry: tauri::State<'_, Arc>, + project_path_key: String, + session_id: Option, + max_bytes: Option, +) -> Result { + crate::commands::terminal::terminal_read_tail(registry.inner(), project_path_key, session_id, max_bytes) +} + +// ===== shell ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn shell_run( + registry: tauri::State<'_, Arc>, + workdir: String, + command: String, + cwd: Option, + timeout_ms: Option, + max_timeout_ms: Option, + provider_id: Option, + run_id: Option, +) -> Result { + crate::commands::shell::shell_run(registry.inner(), workdir, command, cwd, timeout_ms, max_timeout_ms, provider_id, run_id).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn runtime_cancel( + registry: tauri::State<'_, Arc>, + run_id: String, +) -> ShellCancelResponse { + crate::commands::shell::runtime_cancel(registry.inner(), run_id) +} + +// ===== chat_file_links ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn open_chat_file_link( + conversation_id: String, + workdir: String, + path: String, + source: String, + line: Option, + end_line: Option, + column: Option, + open_in_file_manager: Option, +) -> Result { + crate::commands::chat_file_links::open_chat_file_link(conversation_id, workdir, path, source, line, end_line, column, open_in_file_manager).await +} + +// ===== fs ===== +#[tauri::command] +pub async fn fs_read_image_source( + workdir: String, + source: String, + source_type: Option, + mime_type: Option, +) -> Result { + crate::commands::fs::fs_read_image_source(workdir, source, source_type, mime_type).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_read_workspace_image( + workdir: String, + path: String, +) -> Result { + crate::commands::fs::fs_read_workspace_image(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_read_text( + workdir: String, + path: String, + start_line: Option, + limit: Option, + page_start: Option, + page_limit: Option, + cell_start: Option, + cell_limit: Option, +) -> Result { + crate::commands::fs::fs_read_text(workdir, path, start_line, limit, page_start, page_limit, cell_start, cell_limit).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_read_editable_text( + workdir: String, + path: String, +) -> Result { + crate::commands::fs::fs_read_editable_text(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_path_status( + workdir: String, + path: String, +) -> Result { + crate::commands::fs::fs_path_status(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_write_text( + workdir: String, + path: String, + content: String, + mode: String, + expected_mtime_ms: Option, + expected_content_hash: Option, +) -> Result { + crate::commands::fs::fs_write_text(workdir, path, content, mode, expected_mtime_ms, expected_content_hash).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_edit_text( + workdir: String, + path: String, + old_string: String, + new_string: String, + expected_replacements: Option, + replace_all: Option, + expected_mtime_ms: Option, + expected_content_hash: Option, +) -> Result { + crate::commands::fs::fs_edit_text(workdir, path, old_string, new_string, expected_replacements, replace_all, expected_mtime_ms, expected_content_hash).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_delete( + workdir: String, + path: String, +) -> Result { + crate::commands::fs::fs_delete(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_open_workspace_path( + workdir: String, + path: String, + mode: Option, +) -> Result { + crate::commands::fs::fs_open_workspace_path(workdir, path, mode).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_create_dir( + workdir: String, + path: String, +) -> Result { + crate::commands::fs::fs_create_dir(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_rename( + workdir: String, + from_path: String, + to_path: String, +) -> Result { + crate::commands::fs::fs_rename(workdir, from_path, to_path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_roots( +) -> Result { + crate::commands::fs::fs_roots().await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_list_dirs( + path: String, + max_results: Option, +) -> Result { + crate::commands::fs::fs_list_dirs(path, max_results).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_list( + workdir: String, + path: Option, + depth: Option, + offset: Option, + max_results: Option, + show_hidden: Option, +) -> Result { + crate::commands::fs::fs_list(workdir, path, depth, offset, max_results, show_hidden).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_glob( + workdir: String, + path: Option, + pattern: String, + offset: Option, + max_results: Option, + sort_by: Option, +) -> Result { + crate::commands::fs::fs_glob(workdir, path, pattern, offset, max_results, sort_by).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_grep( + workdir: String, + path: Option, + pattern: String, + file_pattern: Option, + ignore_case: Option, + output_mode: Option, + head_limit: Option, + offset: Option, + context: Option, + multiline: Option, +) -> Result { + crate::commands::fs::fs_grep(workdir, path, pattern, file_pattern, ignore_case, output_mode, head_limit, offset, context, multiline).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_mention_list( + workdir: String, + max_results: Option, + query: Option, + show_hidden: Option, +) -> Result { + crate::commands::fs::fs_mention_list(workdir, max_results, query, show_hidden).await +} + +// ===== git ===== +#[tauri::command(rename_all = "snake_case")] +pub async fn git_status( + workdir: String, +) -> Result { + crate::commands::git::git_status(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_discover_repositories( + workdir: String, +) -> Result { + crate::commands::git::git_discover_repositories(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_branches( + workdir: String, +) -> Result { + crate::commands::git::git_branches(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_switch_branch( + workdir: String, + branch: String, + kind: Option, +) -> Result { + crate::commands::git::git_switch_branch(workdir, branch, kind).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_create_branch( + workdir: String, + branch: String, + start_point: Option, +) -> Result { + crate::commands::git::git_create_branch(workdir, branch, start_point).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_init( + workdir: String, + branch: Option, + user_name: Option, + user_email: Option, +) -> Result { + crate::commands::git::git_init(workdir, branch, user_name, user_email).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_clone_repository( + parent: String, + name: String, + remote_url: String, + branch: Option, +) -> Result { + crate::commands::git::git_clone_repository(parent, name, remote_url, branch).await +} + +#[tauri::command(rename_all = "snake_case")] +pub fn git_clone_repository_start( + registry: tauri::State<'_, Arc>, + parent: String, + name: String, + remote_url: String, + branch: Option, +) -> Result { + crate::commands::git::git_clone_repository_start(registry.inner(), parent, name, remote_url, branch) +} + +#[tauri::command] +pub fn git_clone_repository_tasks( + registry: tauri::State<'_, Arc>, +) -> Result, String> { + crate::commands::git::git_clone_repository_tasks(registry.inner()) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn git_clone_repository_cancel( + registry: tauri::State<'_, Arc>, + task_id: String, +) -> Result { + crate::commands::git::git_clone_repository_cancel(registry.inner(), task_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub fn git_clone_repository_dismiss( + registry: tauri::State<'_, Arc>, + task_id: String, +) -> Result, String> { + crate::commands::git::git_clone_repository_dismiss(registry.inner(), task_id) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_list_remote_branches( + remote_url: String, +) -> Result { + crate::commands::git::git_list_remote_branches(remote_url).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_diff( + workdir: String, + mode: Option, + path: Option, +) -> Result { + crate::commands::git::git_diff(workdir, mode, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_log( + workdir: String, + limit: Option, + skip: Option, +) -> Result { + crate::commands::git::git_log(workdir, limit, skip).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_commit_details( + workdir: String, + commit: String, +) -> Result { + crate::commands::git::git_commit_details(workdir, commit).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_compare_commit_with_remote( + workdir: String, + commit: String, +) -> Result { + crate::commands::git::git_compare_commit_with_remote(workdir, commit).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_commit_diff( + workdir: String, + commit: String, + path: Option, +) -> Result { + crate::commands::git::git_commit_diff(workdir, commit, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_stage( + workdir: String, + path: String, +) -> Result { + crate::commands::git::git_stage(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_stage_all( + workdir: String, +) -> Result { + crate::commands::git::git_stage_all(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_unstage( + workdir: String, + path: String, +) -> Result { + crate::commands::git::git_unstage(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_unstage_all( + workdir: String, +) -> Result { + crate::commands::git::git_unstage_all(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_discard( + workdir: String, + path: String, + old_path: Option, +) -> Result { + crate::commands::git::git_discard(workdir, path, old_path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_discard_all( + workdir: String, +) -> Result { + crate::commands::git::git_discard_all(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_add_to_gitignore( + workdir: String, + path: String, +) -> Result { + crate::commands::git::git_add_to_gitignore(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_open_system_file_location( + workdir: String, + path: String, +) -> Result { + crate::commands::git::git_open_system_file_location(workdir, path).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_commit( + workdir: String, + message: String, +) -> Result { + crate::commands::git::git_commit(workdir, message).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_fetch( + workdir: String, +) -> Result { + crate::commands::git::git_fetch(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_pull( + workdir: String, +) -> Result { + crate::commands::git::git_pull(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_set_remote( + workdir: String, + remote_url: String, +) -> Result { + crate::commands::git::git_set_remote(workdir, remote_url).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_push( + workdir: String, +) -> Result { + crate::commands::git::git_push(workdir).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_delete_branch( + workdir: String, + branch: String, + force: Option, +) -> Result { + crate::commands::git::git_delete_branch(workdir, branch, force).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_rename_branch( + workdir: String, + branch: String, + new_branch: String, +) -> Result { + crate::commands::git::git_rename_branch(workdir, branch, new_branch).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_stash_push( + workdir: String, + message: Option, +) -> Result { + crate::commands::git::git_stash_push(workdir, message).await +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn git_stash_pop( + workdir: String, +) -> Result { + crate::commands::git::git_stash_pop(workdir).await +} + +// ===== subagent_worktree ===== +#[tauri::command] +pub async fn subagent_worktree_create( + input: SubagentWorktreeCreateInput, +) -> Result { + crate::commands::subagent_worktree::subagent_worktree_create(input).await +} + +#[tauri::command] +pub async fn subagent_worktree_status( + input: SubagentWorktreeStatusInput, +) -> Result { + crate::commands::subagent_worktree::subagent_worktree_status(input).await +} + +#[tauri::command] +pub async fn subagent_worktree_apply( + input: SubagentWorktreeApplyInput, +) -> Result { + crate::commands::subagent_worktree::subagent_worktree_apply(input).await +} + +#[tauri::command] +pub async fn subagent_worktree_cleanup( + input: SubagentWorktreeCleanupInput, +) -> Result { + crate::commands::subagent_worktree::subagent_worktree_cleanup(input).await +} + +// ===== proxy ===== +#[tauri::command] +pub fn proxy_get_server_info( + state: tauri::State<'_, Arc>, +) -> ProxyServerInfo { + crate::services::proxy::proxy_get_server_info(state.inner()) +} diff --git a/crates/agent-gui/src-tauri/src/commands/app/app.rs b/crates/agent-gui/src-tauri/src/commands/app/app.rs index 210930cb0..3848ea612 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/app.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/app.rs @@ -1,9 +1,16 @@ -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::{Arc, Mutex}; - -use tauri::{AppHandle, State}; +use std::sync::atomic::{AtomicU8, Ordering}; +#[cfg(feature = "desktop")] +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +#[cfg(feature = "desktop")] +use std::sync::Mutex; + +#[cfg(feature = "desktop")] +use tauri::AppHandle; +#[cfg(feature = "desktop")] use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; +#[cfg(feature = "desktop")] use crate::runtime::terminal::TerminalSessionRegistry; pub type CloseWindowBehaviorState = AtomicU8; @@ -12,28 +19,31 @@ pub const CLOSE_WINDOW_BEHAVIOR_MINIMIZE: u8 = 0; pub const CLOSE_WINDOW_BEHAVIOR_EXIT: u8 = 1; /// 已注册全局快捷键 -> 动作 的映射,供插件回调反查动作。 +#[cfg(feature = "desktop")] #[derive(Default)] pub struct GlobalShortcutRegistry { entries: Mutex>, } /// 主窗口置顶状态(快捷键切换用;独立 newtype 避免与其他 AtomicBool 状态类型冲突)。 +#[cfg(feature = "desktop")] #[derive(Default)] pub struct WindowPinState(pub AtomicBool); /// 前端查询当前置顶状态(webview 重载后恢复置顶指示器)。 -#[tauri::command] -pub fn app_window_pinned(pin_state: State<'_, Arc>) -> bool { +#[cfg(feature = "desktop")] +pub fn app_window_pinned(pin_state: &Arc) -> bool { pin_state.0.load(Ordering::SeqCst) } /// 前端主动切换置顶(置顶指示器点击取消);状态变更仍经 /// `global-shortcut:pin-changed` 事件广播回前端。 -#[tauri::command] +#[cfg(feature = "desktop")] pub fn app_toggle_window_pin(app: AppHandle) { - crate::toggle_main_window_pin(&app); + crate::desktop::toggle_main_window_pin(&app); } +#[cfg(feature = "desktop")] impl GlobalShortcutRegistry { pub fn lookup_action(&self, shortcut: &Shortcut) -> Option { let entries = self.entries.lock().ok()?; @@ -50,6 +60,7 @@ impl GlobalShortcutRegistry { } } +#[cfg(feature = "desktop")] #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct GlobalShortcutBinding { @@ -57,6 +68,7 @@ pub struct GlobalShortcutBinding { pub accelerator: String, } +#[cfg(feature = "desktop")] #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct GlobalShortcutFailure { @@ -68,11 +80,11 @@ pub struct GlobalShortcutFailure { /// 全量替换式注册:本命令是插件注册的唯一入口,`unregister_all` 会清掉 /// 插件上的所有快捷键。日后若有其他模块要注册全局快捷键,必须并入本命令 /// 的 bindings 走同一条替换路径,不能自行调用插件 register。 -#[tauri::command] +#[cfg(feature = "desktop")] pub fn app_set_global_shortcuts( app: AppHandle, bindings: Vec, - registry: State<'_, Arc>, + registry: &Arc, ) -> Result, String> { let manager = app.global_shortcut(); manager @@ -119,6 +131,7 @@ pub fn is_close_window_exit(state: &CloseWindowBehaviorState) -> bool { state.load(Ordering::SeqCst) == CLOSE_WINDOW_BEHAVIOR_EXIT } +#[cfg(feature = "desktop")] #[allow(dead_code)] #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -135,7 +148,6 @@ pub struct RuntimePlatformResponse { pub platform: &'static str, } -#[tauri::command] pub fn app_runtime_platform() -> RuntimePlatformResponse { let platform = if cfg!(windows) { "windows" @@ -147,20 +159,19 @@ pub fn app_runtime_platform() -> RuntimePlatformResponse { RuntimePlatformResponse { platform } } -#[tauri::command] pub fn app_set_close_window_behavior( behavior: String, - close_window_behavior: State<'_, Arc>, + close_window_behavior: &Arc, ) -> Result<(), String> { close_window_behavior.store(parse_close_window_behavior(&behavior), Ordering::SeqCst); Ok(()) } -#[tauri::command] +#[cfg(feature = "desktop")] pub fn app_confirmed_exit( app: AppHandle, - allow_exit: State<'_, Arc>, - terminal_registry: State<'_, Arc>, + allow_exit: &Arc, + terminal_registry: &Arc, ) -> Result<(), String> { terminal_registry.close_all()?; allow_exit.store(true, Ordering::SeqCst); @@ -168,14 +179,15 @@ pub fn app_confirmed_exit( Ok(()) } +#[cfg(feature = "desktop")] #[allow(dead_code)] -#[tauri::command] pub async fn app_macos_traffic_light_metrics( window: tauri::Window, ) -> Result, String> { read_macos_traffic_light_metrics(window).await } +#[cfg(feature = "desktop")] #[cfg(not(target_os = "macos"))] #[allow(dead_code)] async fn read_macos_traffic_light_metrics( @@ -184,6 +196,7 @@ async fn read_macos_traffic_light_metrics( Ok(None) } +#[cfg(feature = "desktop")] #[cfg(target_os = "macos")] #[allow(dead_code)] async fn read_macos_traffic_light_metrics( @@ -202,6 +215,7 @@ async fn read_macos_traffic_light_metrics( .map_err(|_| "failed to receive macOS traffic light metrics".to_string())? } +#[cfg(feature = "desktop")] #[cfg(target_os = "macos")] #[allow(dead_code)] fn read_macos_traffic_light_metrics_on_main_thread( @@ -279,6 +293,7 @@ fn read_macos_traffic_light_metrics_on_main_thread( #[cfg(target_os = "macos")] #[allow(dead_code)] +#[cfg(feature = "desktop")] fn macos_window_button_screen_frame( ns_window: &objc2_app_kit::NSWindow, button: &objc2_app_kit::NSButton, diff --git a/crates/agent-gui/src-tauri/src/commands/app/mod.rs b/crates/agent-gui/src-tauri/src/commands/app/mod.rs index 825165356..36a6ee25d 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/mod.rs @@ -1,4 +1,6 @@ pub mod app; pub mod system; +#[cfg(feature = "desktop")] pub mod tray; +#[cfg(feature = "desktop")] pub mod update; diff --git a/crates/agent-gui/src-tauri/src/commands/app/system.rs b/crates/agent-gui/src-tauri/src/commands/app/system.rs index 3f89e745c..897f1cca3 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/system.rs @@ -1,4 +1,5 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +#[cfg(feature = "desktop")] use rfd::FileDialog; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -694,7 +695,7 @@ fn gc_upload_staging_in(base: &Path, now: SystemTime, retention: std::time::Dura /// 启动时清理过期的上传批次;失败只记录,绝不阻断启动。 pub fn gc_upload_staging_on_startup() { - tauri::async_runtime::spawn_blocking(|| { + crate::compat::async_runtime::spawn_blocking(|| { if let Ok(base) = upload_staging_base() { gc_upload_staging_in(&base, SystemTime::now(), UPLOAD_STAGING_RETENTION); } @@ -849,6 +850,7 @@ fn infer_native_attachment_mime(path: &Path, kind: Option<&str>) -> String { } } +#[cfg(feature = "desktop")] fn system_pick_readable_files_sync( workdir: String, max_files: Option, @@ -1207,6 +1209,7 @@ fn system_append_debug_jsonl_sync(conversation_id: String, entry: Value) -> Resu Ok(()) } +#[cfg(feature = "desktop")] fn resolve_pick_folder_initial_dir(initial_workdir: Option) -> Option { let raw = initial_workdir?; let trimmed = raw.trim(); @@ -1332,9 +1335,9 @@ pub(crate) fn system_create_project_folder_sync( }) } -#[tauri::command(rename_all = "snake_case")] +#[cfg(feature = "desktop")] pub async fn system_pick_folder(initial_workdir: Option) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut dialog = FileDialog::new(); if let Some(initial_dir) = resolve_pick_folder_initial_dir(initial_workdir) { dialog = dialog.set_directory(initial_dir); @@ -1348,13 +1351,13 @@ pub async fn system_pick_folder(initial_workdir: Option) -> Result, filter_name: Option, extensions: Option>, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut dialog = FileDialog::new(); if let Some(initial_dir) = resolve_pick_folder_initial_dir(initial_workdir) { dialog = dialog.set_directory(initial_dir); @@ -1372,60 +1375,64 @@ pub async fn system_pick_file( .map_err(|e| format!("system_pick_file join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_create_project_folder( parent: String, name: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || system_create_project_folder_sync(parent, name)) + crate::compat::async_runtime::spawn_blocking(move || system_create_project_folder_sync(parent, name)) .await .map_err(|e| format!("system_create_project_folder join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_pick_readable_files( workdir: String, max_files: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { - system_pick_readable_files_sync(workdir, max_files) + crate::compat::async_runtime::spawn_blocking(move || { + #[cfg(feature = "desktop")] + { + system_pick_readable_files_sync(workdir, max_files) + } + #[cfg(not(feature = "desktop"))] + { + // Headless 无原生文件对话框:该命令仅桌面前端使用。 + let _ = (workdir, max_files); + Err("file picker is unavailable in headless mode".to_string()) + } }) .await .map_err(|e| format!("system_pick_readable_files join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_import_readable_file_paths( workdir: String, paths: Vec, max_files: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_import_readable_file_paths_sync(workdir, paths, max_files) }) .await .map_err(|e| format!("system_import_readable_file_paths join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_import_uploaded_readable_files( workdir: String, files: Vec, max_files: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_import_uploaded_readable_files_from_base64_sync(workdir, files, max_files) }) .await .map_err(|e| format!("system_import_uploaded_readable_files join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_import_pasted_texts( workdir: String, texts: Vec, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let uploads = texts .into_iter() .map(|text| SystemReadableFileUploadInput { @@ -1440,123 +1447,120 @@ pub async fn system_import_pasted_texts( .map_err(|e| format!("system_import_pasted_texts join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_read_uploaded_image_preview( workdir: String, absolute_path: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_read_uploaded_image_preview_sync(workdir, absolute_path) }) .await .map_err(|e| format!("system_read_uploaded_image_preview join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_read_uploaded_native_attachment( workdir: String, absolute_path: Option, kind: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_read_uploaded_native_attachment_sync(workdir, absolute_path, kind) }) .await .map_err(|e| format!("system_read_uploaded_native_attachment join failed: {e}"))? } -#[tauri::command] pub async fn system_list_skill_files() -> Result { - tauri::async_runtime::spawn_blocking(system_list_skill_files_sync) + crate::compat::async_runtime::spawn_blocking(system_list_skill_files_sync) .await .map_err(|e| format!("system_list_skill_files join 失败:{e}"))? } -#[tauri::command] pub async fn system_ensure_builtin_skills( ) -> Result, String> { - tauri::async_runtime::spawn_blocking(crate::services::skills::ensure_builtin_agent_skills_sync) + crate::compat::async_runtime::spawn_blocking(crate::services::skills::ensure_builtin_agent_skills_sync) .await .map_err(|e| format!("system_ensure_builtin_skills join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_manage_skill(payload: Value) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { crate::services::skills::system_manage_skill_sync(payload) }) .await .map_err(|e| format!("system_manage_skill join failed: {e}"))? } -#[tauri::command] pub async fn system_read_skill_text( path: String, offset: Option, length: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || system_read_skill_text_sync(path, offset, length)) + crate::compat::async_runtime::spawn_blocking(move || system_read_skill_text_sync(path, offset, length)) .await .map_err(|e| format!("system_read_skill_text join failed: {e}"))? } -#[tauri::command] pub async fn system_read_skill_metadata( path: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || system_read_skill_metadata_sync(path)) + crate::compat::async_runtime::spawn_blocking(move || system_read_skill_metadata_sync(path)) .await .map_err(|e| format!("system_read_skill_metadata join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn system_append_debug_jsonl( conversation_id: String, entry: Value, ) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_append_debug_jsonl_sync(conversation_id, entry) }) .await .map_err(|e| format!("system_append_debug_jsonl join 失败:{e}"))? } -// 桌面端读系统剪贴板的唯一通道:WKWebView 的 navigator.clipboard.readText() -// 对来自其他应用的剪贴板内容会弹出原生"粘贴"确认气泡(DOM paste access), -// 自定义右键菜单的粘贴必须绕开 webview 直接读原生剪贴板。 +/// 桌面端读系统剪贴板的唯一通道:WKWebView 的 navigator.clipboard.readText() +/// 对来自其他应用的剪贴板内容会弹出原生"粘贴"确认气泡(DOM paste access), +/// 自定义右键菜单的粘贴必须绕开 webview 直接读原生剪贴板。 +/// Headless 构建无剪贴板,直接报错(该命令仅桌面前端会调用)。 fn system_clipboard_read_text_sync() -> Result { - let mut clipboard = - arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; - match clipboard.get_text() { - Ok(text) => Ok(text), - // 剪贴板无文本内容(空/图片/文件)时按空文本处理,前端据此静默收起菜单。 - Err(arboard::Error::ContentNotAvailable) => Ok(String::new()), - Err(e) => Err(format!("clipboard read failed: {e}")), + #[cfg(feature = "desktop")] + { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + match clipboard.get_text() { + Ok(text) => Ok(text), + // 剪贴板无文本内容(空/图片/文件)时按空文本处理,前端据此静默收起菜单。 + Err(arboard::Error::ContentNotAvailable) => Ok(String::new()), + Err(e) => Err(format!("clipboard read failed: {e}")), + } + } + #[cfg(not(feature = "desktop"))] + { + Err("clipboard is unavailable in headless mode".to_string()) } } -#[tauri::command] pub async fn system_clipboard_read_text() -> Result { - tauri::async_runtime::spawn_blocking(system_clipboard_read_text_sync) + crate::compat::async_runtime::spawn_blocking(system_clipboard_read_text_sync) .await .map_err(|e| format!("system_clipboard_read_text join failed: {e}"))? } -#[tauri::command(rename_all = "snake_case")] pub fn system_begin_power_activity( activity_id: String, reason: String, ttl_ms: Option, - power_activity: tauri::State<'_, Arc>, + power_activity: &Arc, ) -> Result<(), String> { power_activity.begin(activity_id, reason, ttl_ms); Ok(()) } -#[tauri::command(rename_all = "snake_case")] pub fn system_end_power_activity( activity_id: String, - power_activity: tauri::State<'_, Arc>, + power_activity: &Arc, ) -> Result<(), String> { power_activity.end(activity_id); Ok(()) diff --git a/crates/agent-gui/src-tauri/src/commands/app/tray.rs b/crates/agent-gui/src-tauri/src/commands/app/tray.rs index 337041877..3d20abdce 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/tray.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/tray.rs @@ -4,11 +4,10 @@ use crate::services::tray::{apply_tray_menu, TrayMenuHandles, TrayMenuModel}; /// 前端推送托盘菜单模型(已本地化文案 + 动态列表 + 状态)。 /// 唯一的托盘内容写入口;apply 内部经菜单句柄代理到主线程执行。 -#[tauri::command(rename_all = "snake_case")] pub async fn app_tray_menu_sync( app: tauri::AppHandle, model: TrayMenuModel, - handles: tauri::State<'_, Arc>, + handles: &Arc, ) -> Result<(), String> { apply_tray_menu(&app, &handles, model) } diff --git a/crates/agent-gui/src-tauri/src/commands/app/update.rs b/crates/agent-gui/src-tauri/src/commands/app/update.rs index 0c3a22cbf..fcbfe2891 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/update.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/update.rs @@ -468,7 +468,6 @@ fn build_updater( .map_err(|error| format!("failed to initialize updater: {error}")) } -#[tauri::command(rename_all = "snake_case")] pub async fn app_update_check( app: AppHandle, include_prerelease: bool, @@ -512,7 +511,6 @@ pub async fn app_update_check( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn app_update_install( app: AppHandle, include_prerelease: bool, @@ -567,7 +565,6 @@ pub async fn app_update_install( )) } -#[tauri::command] pub fn app_restart(app: AppHandle) -> Result<(), String> { // restart() tears the process down without firing ExitRequested/Exit // (sync command, main thread), so the exit-path cleanup must run here or diff --git a/crates/agent-gui/src-tauri/src/commands/automation/cron.rs b/crates/agent-gui/src-tauri/src/commands/automation/cron.rs index 3c0eec8b2..aef4e44a9 100644 --- a/crates/agent-gui/src-tauri/src/commands/automation/cron.rs +++ b/crates/agent-gui/src-tauri/src/commands/automation/cron.rs @@ -6,107 +6,97 @@ use crate::services::automation::{ HooksApplyResponse, PromptCompletionResponse, PromptRunRequest, }; -#[tauri::command(rename_all = "snake_case")] pub async fn cron_validate_expression(expression: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || validate_cron_expression(&expression)) + crate::compat::async_runtime::spawn_blocking(move || validate_cron_expression(&expression)) .await .map_err(|e| format!("cron_validate_expression join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_snapshot( - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.snapshot()) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.snapshot()) .await .map_err(|e| format!("automation_snapshot join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_cron_apply( input: AutomationApplyInput, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.cron_apply(input)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.cron_apply(input)) .await .map_err(|e| format!("automation_cron_apply join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_hooks_apply( input: AutomationApplyInput, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.hooks_apply(input)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.hooks_apply(input)) .await .map_err(|e| format!("automation_hooks_apply join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_list_runs( task_id: String, limit: Option, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result, String> { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.list_runs(&task_id, limit.unwrap_or(100))) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.list_runs(&task_id, limit.unwrap_or(100))) .await .map_err(|e| format!("automation_list_runs join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_clear_runs( task_id: String, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) .await .map_err(|e| format!("automation_clear_runs join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_run_cron_now( task_id: String, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) .await .map_err(|e| format!("automation_run_cron_now join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_claim_prompt_runs( - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result, String> { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.claim_prompt_runs()) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.claim_prompt_runs()) .await .map_err(|e| format!("automation_claim_prompt_runs join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_release_prompt_run( execution_id: String, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result<(), String> { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.release_prompt_run(&execution_id)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.release_prompt_run(&execution_id)) .await .map_err(|e| format!("automation_release_prompt_run join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn automation_complete_prompt_run( input: CompletePromptRunInput, - store: tauri::State<'_, Arc>, + store: &Arc, ) -> Result { - let store = Arc::clone(store.inner()); - tauri::async_runtime::spawn_blocking(move || store.complete_prompt_run(input)) + let store = Arc::clone(store); + crate::compat::async_runtime::spawn_blocking(move || store.complete_prompt_run(input)) .await .map_err(|e| format!("automation_complete_prompt_run join 失败:{e}"))? } diff --git a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs index a806c15bf..d46fbec87 100644 --- a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs +++ b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs @@ -266,42 +266,39 @@ fn truncate_response(text: &str) -> String { out } -#[tauri::command(rename_all = "snake_case")] pub async fn hook_run_script( workdir: Option, script: String, timeout_ms: Option, scope_id: Option, context: Option>, - registry: tauri::State<'_, Arc>, + registry: &Arc, ) -> Result { - let registry = Arc::clone(registry.inner()); + let registry = Arc::clone(registry); let envs = normalize_context(context); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { run_hook_script_sync(®istry, workdir, script, timeout_ms, scope_id, envs) }) .await .map_err(|e| format!("hook_run_script join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn hook_run_http_requests( requests: Vec, scope_id: Option, - registry: tauri::State<'_, Arc>, + registry: &Arc, ) -> Result { - let registry = Arc::clone(registry.inner()); - tauri::async_runtime::spawn_blocking(move || { + let registry = Arc::clone(registry); + crate::compat::async_runtime::spawn_blocking(move || { run_hook_http_requests_sync(®istry, requests, scope_id) }) .await .map_err(|e| format!("hook_run_http_requests join 失败:{e}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn hook_cancel_scope( scope_id: String, - registry: tauri::State<'_, Arc>, + registry: &Arc, ) -> Result<(), String> { registry.cancel(scope_id.trim()) } diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/ccs_import.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/ccs_import.rs index 88547c8ca..5a6a76d7c 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/ccs_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/ccs_import.rs @@ -19,9 +19,8 @@ pub struct CcsProvidersResponse { pub providers: Vec, } -#[tauri::command] pub async fn settings_list_ccswitch_providers() -> Result { - tauri::async_runtime::spawn_blocking(|| { + crate::compat::async_runtime::spawn_blocking(|| { let candidates = ccswitch_db_candidates(); let path = candidates.iter().find(|path| path.exists()); let providers = match path { diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs index b05ab4973..e225f131e 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs @@ -79,20 +79,18 @@ struct CherryImportScan { providers: Vec, } -#[tauri::command] pub async fn settings_list_cherry_studio_providers() -> Result { - tauri::async_runtime::spawn_blocking(|| { + crate::compat::async_runtime::spawn_blocking(|| { cherry_scan_candidates(&cherry_user_data_candidates(), false) }) .await .map_err(|error| format!("settings_list_cherry_studio_providers join 失败:{error}"))? } -#[tauri::command] pub async fn settings_list_cherry_studio_providers_from_path( data_path: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let selected = PathBuf::from(data_path.trim()); if selected.as_os_str().is_empty() { return Err("未选择 Cherry Studio 数据目录".to_string()); diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/commands.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/commands.rs index d41e75460..c47eb9e90 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/commands.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/commands.rs @@ -1,6 +1,5 @@ -#[tauri::command] pub async fn settings_load_all() -> Result { - tauri::async_runtime::spawn_blocking(|| { + crate::compat::async_runtime::spawn_blocking(|| { let conn = open_db()?; let default_workdir = default_project_workdir()?; Ok(SettingsLoadResponse { @@ -18,9 +17,8 @@ pub async fn settings_load_all() -> Result { .map_err(|e| format!("settings_load_all join 失败:{e}"))? } -#[tauri::command] pub async fn settings_save_providers(payload: Value) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_providers(&mut conn, payload) }) @@ -28,12 +26,11 @@ pub async fn settings_save_providers(payload: Value) -> Result<(), String> { .map_err(|e| format!("settings_save_providers join 失败:{e}"))? } -#[tauri::command] pub async fn settings_save_system( payload: Value, - automation_scheduler: tauri::State<'_, Arc>, + automation_scheduler: &Arc, ) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_system(&mut conn, payload)?; // 保存成功后刷新全局代理状态,让 shell env 注入与出网代理即时生效。 @@ -47,9 +44,8 @@ pub async fn settings_save_system( Ok(()) } -#[tauri::command] pub async fn settings_save_mcp(payload: Value) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_mcp(&mut conn, payload) }) @@ -57,12 +53,11 @@ pub async fn settings_save_mcp(payload: Value) -> Result<(), String> { .map_err(|e| format!("settings_save_mcp join 失败:{e}"))? } -#[tauri::command] pub async fn settings_save_remote( payload: Value, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { - let normalized = tauri::async_runtime::spawn_blocking(move || { + let normalized = crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_remote(&mut conn, payload) }) @@ -71,9 +66,8 @@ pub async fn settings_save_remote( gateway_controller.apply_config(normalized) } -#[tauri::command] pub async fn settings_save_memory(payload: Value) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_memory(&mut conn, payload) }) @@ -81,9 +75,8 @@ pub async fn settings_save_memory(payload: Value) -> Result<(), String> { .map_err(|e| format!("settings_save_memory join 失败:{e}"))? } -#[tauri::command] pub async fn settings_save_agents(payload: Value) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_agents(&mut conn, payload) }) @@ -91,9 +84,8 @@ pub async fn settings_save_agents(payload: Value) -> Result<(), String> { .map_err(|e| format!("settings_save_agents join 失败:{e}"))? } -#[tauri::command] pub async fn settings_save_ssh(payload: Value) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_ssh(&mut conn, payload) }) @@ -101,9 +93,8 @@ pub async fn settings_save_ssh(payload: Value) -> Result<(), String> { .map_err(|e| format!("settings_save_ssh join 失败:{e}"))? } -#[tauri::command] pub async fn settings_apply_ssh_patch(payload: Value) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; apply_ssh_patch_with_conn(&mut conn, payload) }) @@ -111,12 +102,11 @@ pub async fn settings_apply_ssh_patch(payload: Value) -> Result Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let deleted = reset_runtime_ssh_known_host(&host, port)?; Ok(SshKnownHostResetResponse { deleted }) }) diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/branch.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/branch.rs index a3367f4e4..3e5ebceb6 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/branch.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/branch.rs @@ -214,7 +214,7 @@ pub(crate) async fn chat_history_branch_inner( id: String, anchor: ChatHistoryMessageRef, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; chat_history_branch_sync(&mut conn, &id, &anchor) }) @@ -222,11 +222,10 @@ pub(crate) async fn chat_history_branch_inner( .map_err(|e| format!("chat_history_branch join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_branch( id: String, base_message_ref: ChatHistoryMessageRef, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_branch_inner(id, base_message_ref).await?; gateway_controller diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs index cd4355afc..ddf0f20ab 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs @@ -1,11 +1,10 @@ -#[tauri::command] pub async fn chat_history_list( page: i64, page_size: i64, cwd: Option, cwd_empty: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; list_chat_history_sync_with_filter( &conn, @@ -21,9 +20,8 @@ pub async fn chat_history_list( .map_err(|e| format!("chat_history_list join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_workdirs() -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; list_chat_history_workdirs_sync(&conn) }) @@ -31,23 +29,21 @@ pub async fn chat_history_workdirs() -> Result Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { list_shared_chat_history_page_sync(page, page_size) }) .await .map_err(|e| format!("chat_history_shared_list join failed: {e}"))? } -#[tauri::command] pub async fn chat_history_search( args: ChatHistorySearchArgs, ) -> Result { - tauri::async_runtime::spawn_blocking(move || search_chat_history_sync(args)) + crate::compat::async_runtime::spawn_blocking(move || search_chat_history_sync(args)) .await .map_err(|e| format!("chat_history_search join 失败:{e}"))? } @@ -55,7 +51,7 @@ pub async fn chat_history_search( pub(crate) async fn chat_history_get_summary_inner( id: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; get_summary_by_id(&conn, &id) }) @@ -66,7 +62,7 @@ pub(crate) async fn chat_history_get_summary_inner( // 桌面前端已迁移到窗口化的 chat_history_get_window;全量读取仅剩 // gateway_bridge 的服务端投影在用,因此不再作为 webview command 暴露。 pub async fn chat_history_get(id: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let chat_id = id.trim().to_string(); if chat_id.is_empty() { return Err("历史对话 id 不能为空".to_string()); @@ -89,7 +85,7 @@ pub(crate) async fn chat_history_get_tail( id: String, max_messages: i64, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let chat_id = id.trim().to_string(); if chat_id.is_empty() { return Err("历史对话 id 不能为空".to_string()); @@ -210,7 +206,6 @@ pub(crate) fn chat_history_get_window_sync( Ok(result) } -#[tauri::command] pub async fn chat_history_get_window( id: String, max_messages: i64, @@ -218,7 +213,7 @@ pub async fn chat_history_get_window( expected_revision: Option, include_active_segment: bool, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; chat_history_get_window_sync( &mut conn, @@ -236,7 +231,7 @@ pub async fn chat_history_get_window( pub(crate) async fn chat_history_upsert_inner( input: ChatHistoryUpsertInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { validate_upsert_input(&input)?; let conversation = ChatHistoryConversationInput { id: input.id.clone(), @@ -277,10 +272,9 @@ pub(crate) async fn chat_history_upsert_inner( .map_err(|e| format!("chat_history_upsert join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_upsert( input: ChatHistoryUpsertInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_upsert_inner(input).await?; gateway_controller @@ -292,7 +286,7 @@ pub async fn chat_history_upsert( pub(crate) async fn chat_history_upsert_active_segment_inner( input: ChatHistorySegmentMutationInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { validate_segment_mutation_input(&input)?; let mut conn = open_db()?; let tx = conn @@ -312,10 +306,9 @@ pub(crate) async fn chat_history_upsert_active_segment_inner( .map_err(|e| format!("chat_history_upsert_active_segment join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_upsert_active_segment( input: ChatHistorySegmentMutationInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_upsert_active_segment_inner(input).await?; gateway_controller @@ -327,7 +320,7 @@ pub async fn chat_history_upsert_active_segment( pub(crate) async fn chat_history_append_segment_inner( input: ChatHistorySegmentMutationInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { validate_segment_mutation_input(&input)?; let mut conn = open_db()?; let tx = conn @@ -348,10 +341,9 @@ pub(crate) async fn chat_history_append_segment_inner( .map_err(|e| format!("chat_history_append_segment join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_append_segment( input: ChatHistorySegmentMutationInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_append_segment_inner(input).await?; gateway_controller @@ -364,7 +356,7 @@ pub(crate) async fn chat_history_rename_inner( id: String, title: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; rename_chat_history_sync(&conn, &id, &title) }) @@ -372,11 +364,10 @@ pub(crate) async fn chat_history_rename_inner( .map_err(|e| format!("chat_history_rename join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_rename( id: String, title: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_rename_inner(id, title).await?; gateway_controller @@ -389,7 +380,7 @@ pub(crate) async fn chat_history_set_pinned_inner( id: String, is_pinned: bool, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; set_chat_history_pinned_sync(&conn, &id, is_pinned) }) @@ -397,11 +388,10 @@ pub(crate) async fn chat_history_set_pinned_inner( .map_err(|e| format!("chat_history_set_pinned join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_set_pinned( id: String, is_pinned: bool, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_set_pinned_inner(id, is_pinned).await?; gateway_controller @@ -414,7 +404,7 @@ pub(crate) async fn chat_history_set_model_inner( id: String, selected_model_json: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; set_chat_history_model_sync(&conn, &id, &selected_model_json) }) @@ -422,11 +412,10 @@ pub(crate) async fn chat_history_set_model_inner( .map_err(|e| format!("chat_history_set_model join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_set_model( id: String, selected_model_json: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let summary = chat_history_set_model_inner(id, selected_model_json).await?; gateway_controller @@ -438,7 +427,7 @@ pub async fn chat_history_set_model( pub(crate) async fn chat_history_share_get_inner( id: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; get_chat_history_share_status_sync(&conn, &id) }) @@ -446,7 +435,6 @@ pub(crate) async fn chat_history_share_get_inner( .map_err(|e| format!("chat_history_share_get join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_share_get(id: String) -> Result { chat_history_share_get_inner(id).await } @@ -456,7 +444,7 @@ pub(crate) async fn chat_history_share_set_inner( enabled: bool, redact_tool_content: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; set_chat_history_share_enabled_sync(&conn, &id, enabled, redact_tool_content) }) @@ -464,12 +452,11 @@ pub(crate) async fn chat_history_share_set_inner( .map_err(|e| format!("chat_history_share_set join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_share_set( id: String, enabled: bool, redact_tool_content: Option, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let status = chat_history_share_set_inner(id, enabled, redact_tool_content).await?; match chat_history_get_summary_inner(status.conversation_id.clone()).await { @@ -486,7 +473,7 @@ pub async fn chat_history_share_set( pub(crate) async fn chat_history_share_resolve_inner( token: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; resolve_chat_history_share_sync(&conn, &token) }) diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/delete.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/delete.rs index d47b76add..24f403255 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/delete.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/delete.rs @@ -42,7 +42,7 @@ fn delete_chat_history_sync( } pub(crate) async fn chat_history_delete_inner(id: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let chat_id = id.trim().to_string(); let mut conn = open_db()?; let mut subagent_prune_result = delete_chat_history_sync(&mut conn, &chat_id)?; @@ -59,10 +59,9 @@ pub(crate) async fn chat_history_delete_inner(id: String) -> Result<(), String> .map_err(|e| format!("chat_history_delete join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_delete( id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { let conversation_id = id.trim().to_string(); chat_history_delete_inner(id).await?; diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/replace.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/replace.rs index cb1e173f9..b2b74e57a 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/replace.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/replace.rs @@ -138,7 +138,7 @@ pub(crate) async fn chat_history_replace_from_message_inner( max_messages: i64, expected_revision: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; chat_history_replace_from_message_sync( &mut conn, @@ -153,14 +153,13 @@ pub(crate) async fn chat_history_replace_from_message_inner( .map_err(|e| format!("chat_history_replace_from_message join 失败:{e}"))? } -#[tauri::command] pub async fn chat_history_replace_from_message( id: String, base_message_ref: ChatHistoryMessageRef, replacement_message: Value, max_messages: i64, expected_revision: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { let result = chat_history_replace_from_message_inner( id, diff --git a/crates/agent-gui/src-tauri/src/commands/history/subagent_store.rs b/crates/agent-gui/src-tauri/src/commands/history/subagent_store.rs index b2a220b93..0e288da25 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/subagent_store.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/subagent_store.rs @@ -1251,11 +1251,10 @@ pub(crate) fn list_subagent_messages_sync( // Tauri commands // --------------------------------------------------------------------------- -#[tauri::command] pub async fn subagent_identity_upsert( input: SubagentIdentityUpsertInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; upsert_subagent_identity_sync(&conn, &input) }) @@ -1263,11 +1262,10 @@ pub async fn subagent_identity_upsert( .map_err(|e| format!("subagent_identity_upsert join failed: {e}"))? } -#[tauri::command] pub async fn subagent_identity_list( input: SubagentIdentityListInput, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; list_subagent_identities_sync(&conn, &input) }) @@ -1275,9 +1273,8 @@ pub async fn subagent_identity_list( .map_err(|e| format!("subagent_identity_list join failed: {e}"))? } -#[tauri::command] pub async fn subagent_run_save(input: SubagentRunSaveInput) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; save_subagent_run_sync(&mut conn, &input) }) @@ -1285,11 +1282,10 @@ pub async fn subagent_run_save(input: SubagentRunSaveInput) -> Result<(), String .map_err(|e| format!("subagent_run_save join failed: {e}"))? } -#[tauri::command] pub async fn subagent_run_list( input: SubagentRunListInput, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; list_subagent_runs_sync(&conn, &input) }) @@ -1297,11 +1293,10 @@ pub async fn subagent_run_list( .map_err(|e| format!("subagent_run_list join failed: {e}"))? } -#[tauri::command] pub async fn subagent_run_load( input: SubagentRunLoadInput, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; load_subagent_run_sync(&conn, &input.id) }) @@ -1309,20 +1304,18 @@ pub async fn subagent_run_load( .map_err(|e| format!("subagent_run_load join failed: {e}"))? } -#[tauri::command] pub async fn subagent_run_prune( input: SubagentRunPruneInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || prune_subagent_runs(input)) + crate::compat::async_runtime::spawn_blocking(move || prune_subagent_runs(input)) .await .map_err(|e| format!("subagent_run_prune join failed: {e}"))? } -#[tauri::command] pub async fn subagent_message_append( input: SubagentMessageAppendInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; append_subagent_message_sync(&mut conn, &input) }) @@ -1330,11 +1323,10 @@ pub async fn subagent_message_append( .map_err(|e| format!("subagent_message_append join failed: {e}"))? } -#[tauri::command] pub async fn subagent_message_list( input: SubagentMessageListInput, ) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; list_subagent_messages_sync(&conn, &input) }) diff --git a/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs b/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs index 8bc1f4a91..b29e78ad4 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs @@ -14,32 +14,29 @@ use crate::services::tunnel::{ }; use crate::services::workspace_watch::WatchSource; -#[tauri::command] pub async fn provider_usage_query( provider_id: String, refresh: bool, - provider_usage_service: tauri::State<'_, Arc>, + provider_usage_service: &Arc, ) -> Result { Ok(provider_usage_service.query(&provider_id, refresh).await) } -#[tauri::command] pub async fn provider_usage_test( provider_id: String, config_json: String, - provider_usage_service: tauri::State<'_, Arc>, + provider_usage_service: &Arc, ) -> Result { Ok(provider_usage_service .test(&provider_id, &config_json) .await) } -#[tauri::command] pub async fn gateway_connect( payload: Option, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { - let mut config = tauri::async_runtime::spawn_blocking(move || { + let mut config = crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; let persisted = load_remote_settings(&conn)?; let mut requested = match payload { @@ -56,25 +53,22 @@ pub async fn gateway_connect( gateway_controller.apply_config(config) } -#[tauri::command] pub fn gateway_disconnect( - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.disconnect_runtime() } -#[tauri::command] pub fn gateway_status( - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { Ok(gateway_controller.status()) } -#[tauri::command(rename_all = "snake_case")] pub fn gateway_nudge_connection( reason: Option, force_reconnect: Option, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { gateway_controller.nudge_connection( reason.as_deref().unwrap_or("runtime_wake"), @@ -82,92 +76,83 @@ pub fn gateway_nudge_connection( ) } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_send_chat_ingress_batch( input: GatewayChatIngressBatchInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { gateway_controller.accept_chat_ingress_batch(input).await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_commit_chat_checkpoint( input: GatewayChatCheckpointInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { gateway_controller.commit_chat_checkpoint(input).await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_claim_next( worker_id: String, lease_ms: Option, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result, String> { gateway_controller .claim_next_chat_request(worker_id, lease_ms) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_mark_started( request_id: String, conversation_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .mark_chat_request_started(request_id, conversation_id, worker_id) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_mark_local_started( request_id: String, conversation_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .mark_local_chat_run_started(request_id, conversation_id) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_mark_local_cancelled( request_id: String, conversation_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .mark_local_chat_run_cancelled(request_id, conversation_id) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_mark_queued_in_gui( request_id: String, conversation_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .mark_chat_request_queued_in_gui(request_id, conversation_id, worker_id) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_complete( request_id: String, conversation_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .complete_chat_request(request_id, conversation_id, worker_id) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_fail( request_id: String, conversation_id: Option, @@ -175,7 +160,7 @@ pub async fn gateway_chat_fail( message: String, terminal: bool, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .fail_chat_request( @@ -189,116 +174,103 @@ pub async fn gateway_chat_fail( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_cancel_request( request_id: String, conversation_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .cancel_chat_request(request_id, conversation_id, worker_id) .await } -#[tauri::command(rename_all = "snake_case")] pub fn gateway_chat_heartbeat( request_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.heartbeat_chat_request(request_id, worker_id) } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_chat_runtime_heartbeat( worker_id: String, state: String, visible: bool, active_run_count: u32, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .publish_chat_runtime_status(worker_id, state, visible, active_run_count) .await } -#[tauri::command(rename_all = "snake_case")] pub fn gateway_chat_release_lease( request_id: String, worker_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.release_chat_request_lease(request_id, worker_id) } -#[tauri::command(rename_all = "snake_case")] pub fn gateway_chat_queue_respond( input: GatewayChatQueueResponseInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.respond_chat_queue_request(input) } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_publish_chat_queue_event( input: GatewayChatQueueEventInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.publish_chat_queue_event(input).await } -#[tauri::command] pub async fn gateway_publish_settings_sync( payload: Value, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.publish_settings_sync(payload).await } -#[tauri::command] pub fn gateway_tunnel_state( - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result { Ok(gateway_controller.tunnel_state()) } -#[tauri::command] pub async fn gateway_tunnel_create( input: GatewayTunnelCreateInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.tunnel_create(input).await } -#[tauri::command] pub async fn gateway_tunnel_update( input: GatewayTunnelUpdateInput, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.tunnel_update(input).await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_tunnel_close( tunnel_id: String, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.tunnel_close(tunnel_id).await } -#[tauri::command(rename_all = "snake_case")] pub async fn gateway_tunnel_check( tunnel_id: Option, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller.tunnel_check(tunnel_id).await } -#[tauri::command] pub fn workspace_watch_set( workdirs: Vec, - gateway_controller: tauri::State<'_, Arc>, + gateway_controller: &Arc, ) -> Result<(), String> { gateway_controller .workspace_watch diff --git a/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs b/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs index 2f9bf2dfd..80eb0d937 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs @@ -26,7 +26,7 @@ async fn run_blocking( label: &'static str, f: impl FnOnce() -> Result + Send + 'static, ) -> Result { - tauri::async_runtime::spawn_blocking(f) + crate::compat::async_runtime::spawn_blocking(f) .await .map_err(|e| format!("{label} join failed: {e}"))? } @@ -1641,13 +1641,12 @@ impl McpRuntimeManager { } } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_list_tools( - state: tauri::State<'_, Arc>, + state: &Arc, servers: Vec, ) -> Result, String> { // IMPORTANT: tool listing can block (process spawn / network / pipes). Offload. - let manager = state.inner().clone(); + let manager = state.clone(); run_blocking("mcp_list_tools", move || { let mut out: Vec = Vec::new(); @@ -1692,17 +1691,16 @@ pub async fn mcp_list_tools( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_call_tool( - state: tauri::State<'_, Arc>, - run_registry: tauri::State<'_, Arc>, + state: &Arc, + run_registry: &Arc, server_id: String, tool_name: String, arguments: Value, run_id: Option, ) -> Result { // IMPORTANT: tool call can block (network / pipes / SSE). Offload. - let manager = state.inner().clone(); + let manager = state.clone(); let normalized_run_id = run_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); @@ -1710,7 +1708,7 @@ pub async fn mcp_call_tool( .as_deref() .map(|id| run_registry.register(id)); let registered_token = cancel_token.clone(); - let mut task = tauri::async_runtime::spawn_blocking(move || { + let mut task = crate::compat::async_runtime::spawn_blocking(move || { let id = server_id.trim().to_string(); if id.is_empty() { return Err("server_id cannot be empty".to_string()); @@ -1750,24 +1748,22 @@ pub async fn mcp_call_tool( result } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_runtime_status( - state: tauri::State<'_, Arc>, + state: &Arc, server_id: String, ) -> Result { - let manager = state.inner().clone(); + let manager = state.clone(); run_blocking("mcp_runtime_status", move || { manager.runtime_status(&server_id) }) .await } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_stop_server( - state: tauri::State<'_, Arc>, + state: &Arc, server_id: String, ) -> Result { - let manager = state.inner().clone(); + let manager = state.clone(); run_blocking("mcp_stop_server", move || { let id = server_id.trim().to_string(); let stopped = manager.stop_client(&id)?; @@ -1779,14 +1775,13 @@ pub async fn mcp_stop_server( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_test_server( - state: tauri::State<'_, Arc>, + state: &Arc, server: McpServerConfig, include_schema: Option, persist: Option, ) -> Result { - let manager = state.inner().clone(); + let manager = state.clone(); run_blocking("mcp_test_server", move || { manager.test_client( server, @@ -1798,14 +1793,13 @@ pub async fn mcp_test_server( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn mcp_restart_server( - state: tauri::State<'_, Arc>, + state: &Arc, server: McpServerConfig, include_schema: Option, persist: Option, ) -> Result { - let manager = state.inner().clone(); + let manager = state.clone(); run_blocking("mcp_restart_server", move || { manager.test_client( server, diff --git a/crates/agent-gui/src-tauri/src/commands/integration/memory.rs b/crates/agent-gui/src-tauri/src/commands/integration/memory.rs index ff2b507e0..d03755dbf 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/memory.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/memory.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use tauri::State; use crate::{ commands::chat_history, @@ -17,35 +16,32 @@ use crate::{ }, }; -#[tauri::command] pub async fn memory_list( - state: State<'_, Arc>, + state: &Arc, args: MemoryListArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.list(args)) + crate::compat::async_runtime::spawn_blocking(move || store.list(args)) .await .map_err(|e| format!("memory_list join 失败:{e}"))? } -#[tauri::command] pub async fn memory_read( - state: State<'_, Arc>, + state: &Arc, args: MemoryReadArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.read(args)) + crate::compat::async_runtime::spawn_blocking(move || store.read(args)) .await .map_err(|e| format!("memory_read join 失败:{e}"))? } -#[tauri::command] pub async fn memory_search( - state: State<'_, Arc>, + state: &Arc, args: MemorySearchArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let history_args = args.clone(); let mut response = store.search(args)?; response.history_matches = @@ -56,219 +52,199 @@ pub async fn memory_search( .map_err(|e| format!("memory_search join 失败:{e}"))? } -#[tauri::command] pub async fn memory_write( - state: State<'_, Arc>, + state: &Arc, args: MemoryWriteArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.write(args)) + crate::compat::async_runtime::spawn_blocking(move || store.write(args)) .await .map_err(|e| format!("memory_write join 失败:{e}"))? } -#[tauri::command] pub async fn memory_update( - state: State<'_, Arc>, + state: &Arc, args: MemoryUpdateArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.update(args)) + crate::compat::async_runtime::spawn_blocking(move || store.update(args)) .await .map_err(|e| format!("memory_update join 失败:{e}"))? } -#[tauri::command] pub async fn memory_delete( - state: State<'_, Arc>, + state: &Arc, args: MemoryDeleteArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.delete(args)) + crate::compat::async_runtime::spawn_blocking(move || store.delete(args)) .await .map_err(|e| format!("memory_delete join 失败:{e}"))? } -#[tauri::command] pub async fn memory_delete_project( - state: State<'_, Arc>, + state: &Arc, args: MemoryDeleteProjectArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.delete_project(args)) + crate::compat::async_runtime::spawn_blocking(move || store.delete_project(args)) .await .map_err(|e| format!("memory_delete_project join 失败:{e}"))? } -#[tauri::command] pub async fn memory_accept( - state: State<'_, Arc>, + state: &Arc, args: MemoryAcceptArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.accept(args)) + crate::compat::async_runtime::spawn_blocking(move || store.accept(args)) .await .map_err(|e| format!("memory_accept join 失败:{e}"))? } -#[tauri::command] pub async fn memory_apply_batch( - state: State<'_, Arc>, + state: &Arc, args: MemoryBatchArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.apply_batch(args)) + crate::compat::async_runtime::spawn_blocking(move || store.apply_batch(args)) .await .map_err(|e| format!("memory_apply_batch join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_run_create( - state: State<'_, Arc>, + state: &Arc, args: MemoryOrganizeRunCreateArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_run_create(args)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_run_create(args)) .await .map_err(|e| format!("memory_organize_run_create join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_run_update( - state: State<'_, Arc>, + state: &Arc, args: MemoryOrganizeRunUpdateArgs, ) -> Result, String> { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_run_update(args)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_run_update(args)) .await .map_err(|e| format!("memory_organize_run_update join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_run_list( - state: State<'_, Arc>, + state: &Arc, args: Option, ) -> Result { let store = Arc::clone(&state); let resolved = args.unwrap_or_default(); - tauri::async_runtime::spawn_blocking(move || store.organize_run_list(resolved)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_run_list(resolved)) .await .map_err(|e| format!("memory_organize_run_list join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_run_read( - state: State<'_, Arc>, + state: &Arc, args: MemoryOrganizeRunReadArgs, ) -> Result, String> { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_run_read(args)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_run_read(args)) .await .map_err(|e| format!("memory_organize_run_read join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_run_clear_history( - state: State<'_, Arc>, + state: &Arc, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_run_clear_history()) + crate::compat::async_runtime::spawn_blocking(move || store.organize_run_clear_history()) .await .map_err(|e| format!("memory_organize_run_clear_history join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_due_claim( - state: State<'_, Arc>, + state: &Arc, args: MemoryOrganizeDueClaimArgs, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_due_claim(args)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_due_claim(args)) .await .map_err(|e| format!("memory_organize_due_claim join 失败:{e}"))? } -#[tauri::command] pub async fn memory_organize_due_complete( - state: State<'_, Arc>, + state: &Arc, args: MemoryOrganizeRunUpdateArgs, ) -> Result, String> { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.organize_due_complete(args)) + crate::compat::async_runtime::spawn_blocking(move || store.organize_due_complete(args)) .await .map_err(|e| format!("memory_organize_due_complete join 失败:{e}"))? } -#[tauri::command] pub async fn memory_index_overview( - state: State<'_, Arc>, + state: &Arc, workdir: Option, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.overview(workdir)) + crate::compat::async_runtime::spawn_blocking(move || store.overview(workdir)) .await .map_err(|e| format!("memory_index_overview join 失败:{e}"))? } -#[tauri::command] pub async fn memory_paths_info( - state: State<'_, Arc>, + state: &Arc, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.paths_info()) + crate::compat::async_runtime::spawn_blocking(move || store.paths_info()) .await .map_err(|e| format!("memory_paths_info join 失败:{e}"))? } -#[tauri::command] pub async fn memory_recent_rejections( - state: State<'_, Arc>, + state: &Arc, args: Option, ) -> Result { let store = Arc::clone(&state); let resolved = args.unwrap_or_default(); - tauri::async_runtime::spawn_blocking(move || store.recent_rejections(resolved)) + crate::compat::async_runtime::spawn_blocking(move || store.recent_rejections(resolved)) .await .map_err(|e| format!("memory_recent_rejections join 失败:{e}"))? } -#[tauri::command] pub async fn memory_today_local_date( - state: State<'_, Arc>, + state: &Arc, rollover_hour: Option, ) -> Result { Ok(state.today_local_date(rollover_hour)) } -#[tauri::command] pub async fn memory_today_daily( - state: State<'_, Arc>, + state: &Arc, rollover_hour: Option, ) -> Result, String> { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.today_daily(rollover_hour)) + crate::compat::async_runtime::spawn_blocking(move || store.today_daily(rollover_hour)) .await .map_err(|e| format!("memory_today_daily join 失败:{e}"))? } -#[tauri::command] pub async fn memory_quota_summary( - state: State<'_, Arc>, + state: &Arc, args: Option, ) -> Result { let store = Arc::clone(&state); let resolved = args.unwrap_or_default(); - tauri::async_runtime::spawn_blocking(move || store.quota_summary(resolved)) + crate::compat::async_runtime::spawn_blocking(move || store.quota_summary(resolved)) .await .map_err(|e| format!("memory_quota_summary join 失败:{e}"))? } -#[tauri::command] pub async fn memory_wipe_all( - state: State<'_, Arc>, + state: &Arc, ) -> Result { let store = Arc::clone(&state); - tauri::async_runtime::spawn_blocking(move || store.wipe_all()) + crate::compat::async_runtime::spawn_blocking(move || store.wipe_all()) .await .map_err(|e| format!("memory_wipe_all join 失败:{e}"))? } diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index 07a52333a..7dae4cdbf 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -1,3 +1,8 @@ +// `pub use` re-exports are consumed by the desktop-only `adapters` module +// (and, from PR-E on, the headless command router). Until the headless +// router lands, some re-exports are unused in the headless build. +#![cfg_attr(not(feature = "desktop"), allow(unused_imports))] + #[path = "app/mod.rs"] pub mod app_commands; #[path = "automation/mod.rs"] @@ -13,9 +18,14 @@ pub mod runtime_commands; #[path = "workspace/mod.rs"] pub mod workspace_commands; +#[cfg(feature = "desktop")] +pub mod adapters; + pub use app_commands::app; pub use app_commands::system; +#[cfg(feature = "desktop")] pub use app_commands::tray; +#[cfg(feature = "desktop")] pub use app_commands::update; pub use automation_commands::cron; diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/process.rs b/crates/agent-gui/src-tauri/src/commands/runtime/process.rs index 9a9b0e524..ac96ab6b0 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/process.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/process.rs @@ -1,15 +1,13 @@ use std::sync::Arc; -use tauri::State; use crate::runtime::managed_process::{ ManagedProcessLogResponse, ManagedProcessRegistry, ManagedProcessSnapshot, ManagedProcessStartResponse, ManagedProcessStatusResponse, ManagedProcessStopResponse, }; -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_start( - registry: State<'_, Arc>, + registry: &Arc, workdir: String, command: String, cwd: Option, @@ -19,41 +17,36 @@ pub fn managed_process_start( registry.start(workdir, command, cwd, label, isolated.unwrap_or(false)) } -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_status( - registry: State<'_, Arc>, + registry: &Arc, process_id: Option, ) -> Result { registry.status(process_id) } -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_stop( - registry: State<'_, Arc>, + registry: &Arc, process_id: String, ) -> Result { registry.stop(process_id) } -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_read_log( - registry: State<'_, Arc>, + registry: &Arc, process_id: String, max_bytes: Option, ) -> Result { registry.read_log(process_id, max_bytes) } -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_snapshot( - registry: State<'_, Arc>, + registry: &Arc, ) -> Result { registry.snapshot() } -#[tauri::command(rename_all = "snake_case")] pub fn managed_process_clear( - registry: State<'_, Arc>, + registry: &Arc, process_id: Option, ) -> Result { registry.clear(process_id) diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/sftp.rs b/crates/agent-gui/src-tauri/src/commands/runtime/sftp.rs index 2c98186de..e1b189cf8 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/sftp.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/sftp.rs @@ -1,15 +1,13 @@ use std::sync::Arc; -use tauri::State; use crate::runtime::sftp::{ SftpActionResponse, SftpListResponse, SftpReadTextResponse, SftpSessionRegistry, SftpStatResponse, SftpTransferResponse, }; -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_list( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -21,9 +19,8 @@ pub async fn sftp_list( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_stat( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -35,9 +32,8 @@ pub async fn sftp_stat( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_read_text( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, path: String, @@ -49,9 +45,8 @@ pub async fn sftp_read_text( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_write_text( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, path: String, @@ -71,9 +66,8 @@ pub async fn sftp_write_text( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_mkdir( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -85,9 +79,8 @@ pub async fn sftp_mkdir( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_rename( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -107,9 +100,8 @@ pub async fn sftp_rename( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_delete( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -129,9 +121,8 @@ pub async fn sftp_delete( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn sftp_transfer( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, workdir: String, @@ -142,7 +133,6 @@ pub async fn sftp_transfer( overwrite: Option, ) -> Result { registry - .inner() .clone() .transfer( session_id, @@ -157,18 +147,16 @@ pub async fn sftp_transfer( .await } -#[tauri::command(rename_all = "snake_case")] pub fn sftp_cancel_transfer( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, transfer_id: String, ) -> Result<(), String> { registry.cancel_transfer(session_id, transfer_id) } -#[tauri::command(rename_all = "snake_case")] pub fn sftp_transfer_status( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, transfer_id: String, ) -> Result { diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs index 03749e741..e5ba75458 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use serde::Serialize; -use tauri::State; use crate::runtime::shell_runner::{run_shell_script, ShellRunRegistry, ShellRunResponse}; @@ -10,9 +9,8 @@ pub struct ShellCancelResponse { cancelled: bool, } -#[tauri::command(rename_all = "snake_case")] pub async fn shell_run( - registry: State<'_, Arc>, + registry: &Arc, workdir: String, command: String, cwd: Option, @@ -27,7 +25,7 @@ pub async fn shell_run( let cancel_token = normalized_run_id.as_deref().map(|id| registry.register(id)); let registered_token = cancel_token.clone(); - let join_result = tauri::async_runtime::spawn_blocking(move || { + let join_result = crate::compat::async_runtime::spawn_blocking(move || { run_shell_script( workdir, command, @@ -49,9 +47,8 @@ pub async fn shell_run( /// Cancels any run registered in the shared `ShellRunRegistry` — shell /// commands, MCP tool calls, and SSH exec all park their cancel tokens there. -#[tauri::command(rename_all = "snake_case")] pub fn runtime_cancel( - registry: State<'_, Arc>, + registry: &Arc, run_id: String, ) -> ShellCancelResponse { ShellCancelResponse { diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/terminal.rs b/crates/agent-gui/src-tauri/src/commands/runtime/terminal.rs index 61bcebb50..29df2ff1c 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/terminal.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/terminal.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use tauri::State; use crate::runtime::sftp::SftpSessionRegistry; use crate::runtime::shell_runner::ShellRunRegistry; @@ -13,22 +12,19 @@ use crate::runtime::terminal::{ TerminalSshExecResponse, TerminalSshLatencyResponse, TerminalStreamSnapshotResponse, }; -#[tauri::command(rename_all = "snake_case")] pub fn terminal_shell_options() -> TerminalShellOptionsResponse { runtime_terminal_shell_options() } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_list( - registry: State<'_, Arc>, + registry: &Arc, project_path_key: Option, ) -> TerminalListResponse { registry.list(project_path_key) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_create( - registry: State<'_, Arc>, + registry: &Arc, cwd: String, project_path_key: Option, shell: Option, @@ -39,9 +35,8 @@ pub fn terminal_create( registry.create(cwd, project_path_key, shell, title, cols, rows) } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_create_ssh( - registry: State<'_, Arc>, + registry: &Arc, cwd: String, project_path_key: Option, ssh_host_id: String, @@ -51,7 +46,6 @@ pub async fn terminal_create_ssh( sftp_enabled: Option, ) -> Result { registry - .inner() .clone() .create_ssh( cwd, @@ -65,48 +59,42 @@ pub async fn terminal_create_ssh( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_answer_ssh_prompt( - registry: State<'_, Arc>, + registry: &Arc, prompt_id: String, prompt_answer: Option, trust_host_key: Option, ) -> Result { registry - .inner() .clone() .answer_ssh_prompt(prompt_id, prompt_answer, trust_host_key.unwrap_or(false)) .await } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_cancel_ssh_prompt( - registry: State<'_, Arc>, + registry: &Arc, prompt_id: String, ) -> Result<(), String> { registry.cancel_ssh_prompt(prompt_id) } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_reconnect( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, ) -> Result { - registry.inner().clone().ssh_reconnect(session_id).await + registry.clone().ssh_reconnect(session_id).await } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_latency( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, ) -> Result { registry.ssh_latency(session_id).await } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_exec( - registry: State<'_, Arc>, - run_registry: State<'_, Arc>, + registry: &Arc, + run_registry: &Arc, session_id: String, command: String, cwd: Option, @@ -122,7 +110,6 @@ pub async fn terminal_ssh_exec( .map(|id| run_registry.register(id)); let registered_token = cancel_token.clone(); let result = registry - .inner() .clone() .ssh_exec( session_id, @@ -139,9 +126,8 @@ pub async fn terminal_ssh_exec( result } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_local_forward_start( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, project_path_key: Option, remote_host: String, @@ -149,7 +135,6 @@ pub async fn terminal_ssh_local_forward_start( local_port: Option, ) -> Result { registry - .inner() .clone() .ssh_local_forward_start( session_id, @@ -161,18 +146,16 @@ pub async fn terminal_ssh_local_forward_start( .await } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_ssh_local_forward_list( - registry: State<'_, Arc>, + registry: &Arc, session_id: Option, project_path_key: Option, ) -> Result { registry.ssh_local_forward_list(session_id, project_path_key) } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_local_forward_stop( - registry: State<'_, Arc>, + registry: &Arc, forward_id: String, session_id: Option, ) -> Result { @@ -181,7 +164,6 @@ pub async fn terminal_ssh_local_forward_stop( .await } -#[tauri::command(rename_all = "snake_case")] pub async fn terminal_ssh_local_forward_check_port(local_port: u32) -> Result { let port = normalize_ssh_local_forward_local_port(Some(local_port))?; if port == 0 { @@ -191,52 +173,46 @@ pub async fn terminal_ssh_local_forward_check_port(local_port: u32) -> Result>, + registry: &Arc, project_path_key: String, ) -> Result { registry.ssh_terminal_tabs_list(project_path_key) } -#[tauri::command(rename_all = "snake_case")] pub fn ssh_terminal_tab_open( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, kind: String, ) -> Result { registry.ssh_terminal_tab_open(session_id, kind) } -#[tauri::command(rename_all = "snake_case")] pub fn ssh_terminal_tab_close( - registry: State<'_, Arc>, + registry: &Arc, tab_id: String, ) -> Result { registry.ssh_terminal_tab_close(tab_id) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_stream_attach( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, max_bytes: Option, ) -> Result { registry.stream_attach(session_id, max_bytes) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_stream_input( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, bytes: Vec, ) -> Result<(), String> { registry.input_bytes(session_id, bytes) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_stream_resize( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, cols: u16, rows: u16, @@ -244,19 +220,17 @@ pub fn terminal_stream_resize( registry.stream_resize(session_id, cols, rows) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_rename( - registry: State<'_, Arc>, + registry: &Arc, session_id: String, title: String, ) -> Result { registry.rename(session_id, title) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_close( - registry: State<'_, Arc>, - sftp_registry: State<'_, Arc>, + registry: &Arc, + sftp_registry: &Arc, session_id: String, ) -> Result { let response = registry.close(session_id)?; @@ -264,10 +238,9 @@ pub fn terminal_close( Ok(response) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_close_project( - registry: State<'_, Arc>, - sftp_registry: State<'_, Arc>, + registry: &Arc, + sftp_registry: &Arc, project_path_key: String, ) -> Result { let response = registry.close_project(project_path_key)?; @@ -277,9 +250,8 @@ pub fn terminal_close_project( Ok(response) } -#[tauri::command(rename_all = "snake_case")] pub fn terminal_read_tail( - registry: State<'_, Arc>, + registry: &Arc, project_path_key: String, session_id: Option, max_bytes: Option, diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs index fbea8edca..9ad1b780f 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/chat_file_links.rs @@ -594,7 +594,7 @@ pub(crate) async fn open_chat_file_link_for_conversation( ) })?; - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let _permit = permit; open_chat_file_link_sync( conversation_id, @@ -624,7 +624,6 @@ pub(crate) async fn open_chat_file_link_for_conversation( })? } -#[tauri::command(rename_all = "snake_case")] pub async fn open_chat_file_link( conversation_id: String, workdir: String, diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 3f4bcc996..1a10c091b 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -43,7 +43,7 @@ async fn run_blocking( label: &'static str, f: impl FnOnce() -> Result + Send + 'static, ) -> Result { - tauri::async_runtime::spawn_blocking(f) + crate::compat::async_runtime::spawn_blocking(f) .await .map_err(|e| format!("{label} join failed: {e}"))? } @@ -237,7 +237,7 @@ async fn run_blocking_fs( label: &'static str, f: impl FnOnce() -> Result + Send + 'static, ) -> Result { - tauri::async_runtime::spawn_blocking(f) + crate::compat::async_runtime::spawn_blocking(f) .await .map_err(|e| FsCommandError::other(format!("{label} join failed: {e}")))? } @@ -2487,7 +2487,6 @@ fn fs_read_image_source_impl( } } -#[tauri::command] pub async fn fs_read_image_source( workdir: String, source: String, @@ -2515,7 +2514,6 @@ fn fs_read_workspace_image_impl(wd: &Path, path: &str) -> Result Result Result Result { }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_delete(workdir: String, path: String) -> Result { run_blocking_fs("fs_delete", move || fs_delete_sync(workdir, path)).await } @@ -3358,7 +3350,6 @@ fn fs_open_workspace_path_impl( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_open_workspace_path( workdir: String, path: String, @@ -3410,7 +3401,6 @@ fn fs_create_dir_impl(wd: &Path, path: &str) -> Result Result Result { Ok(FsRootsResponse { roots }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_roots() -> Result { run_blocking("fs_roots", fs_roots_sync).await } @@ -3675,7 +3663,6 @@ pub(crate) fn fs_list_dirs_sync( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_list_dirs( path: String, max_results: Option, @@ -3865,7 +3852,6 @@ fn fs_list_impl( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_list( workdir: String, path: Option, @@ -4005,7 +3991,6 @@ fn fs_glob_impl( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_glob( workdir: String, path: Option, @@ -4325,7 +4310,6 @@ fn fs_grep_impl( }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_grep( workdir: String, path: Option, @@ -4545,7 +4529,6 @@ pub fn fs_mention_list_sync( Ok(MentionListResponse { entries, truncated }) } -#[tauri::command(rename_all = "snake_case")] pub async fn fs_mention_list( workdir: String, max_results: Option, diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 90c2f791a..b68b892d3 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -3132,59 +3132,53 @@ pub(crate) fn git_gateway_clone_task_action_sync( } } -#[tauri::command(rename_all = "snake_case")] pub async fn git_status(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_status_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_status_sync(workdir)) .await .map_err(|error| format!("git_status join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_discover_repositories(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_discover_repositories_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_discover_repositories_sync(workdir)) .await .map_err(|error| format!("git_discover_repositories join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_branches(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_branches_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_branches_sync(workdir)) .await .map_err(|error| format!("git_branches join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_switch_branch( workdir: String, branch: String, kind: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_switch_branch_sync(workdir, branch, kind)) + crate::compat::async_runtime::spawn_blocking(move || git_switch_branch_sync(workdir, branch, kind)) .await .map_err(|error| format!("git_switch_branch join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_create_branch( workdir: String, branch: String, start_point: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { git_create_branch_sync(workdir, branch, start_point) }) .await .map_err(|error| format!("git_create_branch join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_init( workdir: String, branch: Option, user_name: Option, user_email: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { git_init_sync( workdir, branch.unwrap_or_else(|| "main".to_string()), @@ -3196,23 +3190,21 @@ pub async fn git_init( .map_err(|error| format!("git_init join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_clone_repository( parent: String, name: String, remote_url: String, branch: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { git_clone_repository_sync(parent, name, remote_url, branch) }) .await .map_err(|error| format!("git_clone_repository join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub fn git_clone_repository_start( - registry: tauri::State<'_, Arc>, + registry: &Arc, parent: String, name: String, remote_url: String, @@ -3221,235 +3213,209 @@ pub fn git_clone_repository_start( registry.start(parent, name, remote_url, branch) } -#[tauri::command] pub fn git_clone_repository_tasks( - registry: tauri::State<'_, Arc>, + registry: &Arc, ) -> Result, String> { registry.snapshot() } -#[tauri::command(rename_all = "snake_case")] pub fn git_clone_repository_cancel( - registry: tauri::State<'_, Arc>, + registry: &Arc, task_id: String, ) -> Result { registry.cancel(task_id) } -#[tauri::command(rename_all = "snake_case")] pub fn git_clone_repository_dismiss( - registry: tauri::State<'_, Arc>, + registry: &Arc, task_id: String, ) -> Result, String> { registry.dismiss(task_id)?; registry.snapshot() } -#[tauri::command(rename_all = "snake_case")] pub async fn git_list_remote_branches( remote_url: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_list_remote_branches_sync(remote_url)) + crate::compat::async_runtime::spawn_blocking(move || git_list_remote_branches_sync(remote_url)) .await .map_err(|error| format!("git_list_remote_branches join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_diff( workdir: String, mode: Option, path: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_diff_sync(workdir, mode, path)) + crate::compat::async_runtime::spawn_blocking(move || git_diff_sync(workdir, mode, path)) .await .map_err(|error| format!("git_diff join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_log( workdir: String, limit: Option, skip: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_log_sync(workdir, limit, skip)) + crate::compat::async_runtime::spawn_blocking(move || git_log_sync(workdir, limit, skip)) .await .map_err(|error| format!("git_log join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_commit_details( workdir: String, commit: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_commit_details_sync(workdir, commit)) + crate::compat::async_runtime::spawn_blocking(move || git_commit_details_sync(workdir, commit)) .await .map_err(|error| format!("git_commit_details join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_compare_commit_with_remote( workdir: String, commit: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { git_compare_commit_with_remote_sync(workdir, commit) }) .await .map_err(|error| format!("git_compare_commit_with_remote join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_commit_diff( workdir: String, commit: String, path: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_commit_diff_sync(workdir, commit, path)) + crate::compat::async_runtime::spawn_blocking(move || git_commit_diff_sync(workdir, commit, path)) .await .map_err(|error| format!("git_commit_diff join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_stage(workdir: String, path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_stage_sync(workdir, path)) + crate::compat::async_runtime::spawn_blocking(move || git_stage_sync(workdir, path)) .await .map_err(|error| format!("git_stage join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_stage_all(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_stage_all_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_stage_all_sync(workdir)) .await .map_err(|error| format!("git_stage_all join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_unstage(workdir: String, path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_unstage_sync(workdir, path)) + crate::compat::async_runtime::spawn_blocking(move || git_unstage_sync(workdir, path)) .await .map_err(|error| format!("git_unstage join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_unstage_all(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_unstage_all_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_unstage_all_sync(workdir)) .await .map_err(|error| format!("git_unstage_all join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_discard( workdir: String, path: String, old_path: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_discard_sync(workdir, path, old_path)) + crate::compat::async_runtime::spawn_blocking(move || git_discard_sync(workdir, path, old_path)) .await .map_err(|error| format!("git_discard join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_discard_all(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_discard_all_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_discard_all_sync(workdir)) .await .map_err(|error| format!("git_discard_all join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_add_to_gitignore( workdir: String, path: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_add_to_gitignore_sync(workdir, path)) + crate::compat::async_runtime::spawn_blocking(move || git_add_to_gitignore_sync(workdir, path)) .await .map_err(|error| format!("git_add_to_gitignore join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_open_system_file_location( workdir: String, path: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_open_system_file_location_sync(workdir, path)) + crate::compat::async_runtime::spawn_blocking(move || git_open_system_file_location_sync(workdir, path)) .await .map_err(|error| format!("git_open_system_file_location join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_commit(workdir: String, message: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_commit_sync(workdir, message)) + crate::compat::async_runtime::spawn_blocking(move || git_commit_sync(workdir, message)) .await .map_err(|error| format!("git_commit join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_fetch(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_fetch_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_fetch_sync(workdir)) .await .map_err(|error| format!("git_fetch join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_pull(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_pull_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_pull_sync(workdir)) .await .map_err(|error| format!("git_pull join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_set_remote( workdir: String, remote_url: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_set_remote_sync(workdir, remote_url)) + crate::compat::async_runtime::spawn_blocking(move || git_set_remote_sync(workdir, remote_url)) .await .map_err(|error| format!("git_set_remote join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_push(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_push_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_push_sync(workdir)) .await .map_err(|error| format!("git_push join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_delete_branch( workdir: String, branch: String, force: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_delete_branch_sync(workdir, branch, force)) + crate::compat::async_runtime::spawn_blocking(move || git_delete_branch_sync(workdir, branch, force)) .await .map_err(|error| format!("git_delete_branch join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_rename_branch( workdir: String, branch: String, new_branch: String, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { git_rename_branch_sync(workdir, branch, new_branch) }) .await .map_err(|error| format!("git_rename_branch join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_stash_push( workdir: String, message: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || git_stash_push_sync(workdir, message)) + crate::compat::async_runtime::spawn_blocking(move || git_stash_push_sync(workdir, message)) .await .map_err(|error| format!("git_stash_push join 失败:{error}"))? } -#[tauri::command(rename_all = "snake_case")] pub async fn git_stash_pop(workdir: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_stash_pop_sync(workdir)) + crate::compat::async_runtime::spawn_blocking(move || git_stash_pop_sync(workdir)) .await .map_err(|error| format!("git_stash_pop join 失败:{error}"))? } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs index e6779c537..6bd351553 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs @@ -1048,11 +1048,10 @@ pub(crate) fn cleanup_worktree_targets_blocking( } } -#[tauri::command] pub async fn subagent_worktree_create( input: SubagentWorktreeCreateInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let SubagentWorktreeCreateInput { workdir, label } = input; let requested_workdir = canonicalize_existing_dir(&workdir, "workdir")?; let repo_root_raw = run_git(&requested_workdir, &["rev-parse", "--show-toplevel"])?; @@ -1138,33 +1137,30 @@ pub async fn subagent_worktree_create( .map_err(|err| format!("subagent_worktree_create join failed: {err}"))? } -#[tauri::command] pub async fn subagent_worktree_status( input: SubagentWorktreeStatusInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { worktree_status_blocking(input.worktree_root, input.max_diff_chars) }) .await .map_err(|err| format!("subagent_worktree_status join failed: {err}"))? } -#[tauri::command] pub async fn subagent_worktree_apply( input: SubagentWorktreeApplyInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { apply_worktree_changes_blocking(input.parent_workdir, input.worktree_root) }) .await .map_err(|err| format!("subagent_worktree_apply join failed: {err}"))? } -#[tauri::command] pub async fn subagent_worktree_cleanup( input: SubagentWorktreeCleanupInput, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { cleanup_worktree_target_blocking( SubagentWorktreeCleanupTarget { run_id: None, diff --git a/crates/agent-gui/src-tauri/src/compat.rs b/crates/agent-gui/src-tauri/src/compat.rs new file mode 100644 index 000000000..085884219 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/compat.rs @@ -0,0 +1,30 @@ +#![cfg_attr(not(feature = "desktop"), allow(dead_code))] +//! Compatibility layer for code that previously called into +//! `tauri::async_runtime`. Tauri 2.x's async runtime is a thin wrapper +//! around tokio, so the headless (non-desktop) build can call tokio +//! directly with identical semantics. +//! +//! This module exists so business code never has to reference `tauri::` +//! (or tokio-specific runtime plumbing) directly, keeping the desktop and +//! headless builds on the same code path. + +/// Drop-in replacement for `tauri::async_runtime`. +pub mod async_runtime { + /// The handle returned by [`spawn`]. Equivalent to `tauri::async_runtime::JoinHandle`. + pub use tokio::task::JoinHandle; + + /// Spawns a new async task. Equivalent to `crate::compat::async_runtime::spawn` + /// (and `tokio::spawn`). + pub use tokio::task::spawn; + + /// Spawns a blocking task on the blocking pool. Equivalent to + /// `crate::compat::async_runtime::spawn_blocking` (and `tokio::task::spawn_blocking`). + pub use tokio::task::spawn_blocking; + + /// Runs a future to completion on the current tokio runtime. + /// Equivalent to `crate::compat::async_runtime::block_on`. Must be called from + /// within a tokio runtime context (same constraint as tauri's version). + pub fn block_on(future: F) -> F::Output { + tokio::runtime::Handle::current().block_on(future) + } +} diff --git a/crates/agent-gui/src-tauri/src/desktop.rs b/crates/agent-gui/src-tauri/src/desktop.rs new file mode 100644 index 000000000..5cfec0721 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/desktop.rs @@ -0,0 +1,772 @@ +//! Desktop-only runtime (Tauri). Only compiled with the `desktop` feature +//! (the default). Everything here depends on Tauri types; the headless build +//! (`--no-default-features`) skips this module entirely. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::{commands, runtime, services}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::Emitter; +use tauri::Manager; +use tauri::WindowEvent; + +const MAIN_WINDOW_LABEL: &str = "main"; +// Only size + maximized are persisted: POSITION would fight multi-monitor +// layouts we don't manage, VISIBLE would re-show a tray-hidden window on +// startup, and DECORATIONS would override the per-platform window chrome +// (Windows runs undecorated with custom chrome). +pub(crate) const WINDOW_STATE_FLAGS: tauri_plugin_window_state::StateFlags = + tauri_plugin_window_state::StateFlags::SIZE + .union(tauri_plugin_window_state::StateFlags::MAXIMIZED); +const TRAY_SHOW_MENU_ON_LEFT_CLICK: bool = !cfg!(target_os = "windows"); +const TERMINAL_EXIT_REQUESTED_EVENT: &str = "terminal:exit-requested"; +/// 统一的「前端动作」事件:托盘菜单与全局快捷键中需要前端语义的动作 +/// (开会话/新建对话/切工作空间/改主题/停止运行等)都经此事件转发, +/// 两端各自监听并只处理自己拥有的 action(App.tsx / ChatPage.tsx)。 +const APP_ACTION_EVENT: &str = "app:action"; +/// Rust 直连动作的结果反馈(如托盘触发 cron):前端收到后 toast 呈现。 +const APP_ACTION_FEEDBACK_EVENT: &str = "app:action-feedback"; + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct TerminalExitRequestedEvent { + running_count: usize, +} + +macro_rules! app_invoke_handler { + () => { + tauri::generate_handler![ + // Chat history + commands::adapters::chat_history_list, + commands::adapters::chat_history_workdirs, + commands::adapters::chat_history_shared_list, + commands::adapters::chat_history_search, + commands::adapters::chat_history_get_window, + commands::adapters::chat_history_upsert, + commands::adapters::chat_history_upsert_active_segment, + commands::adapters::chat_history_append_segment, + commands::adapters::chat_history_rename, + commands::adapters::chat_history_branch, + commands::adapters::chat_history_replace_from_message, + commands::adapters::chat_history_set_pinned, + commands::adapters::chat_history_set_model, + commands::adapters::chat_history_share_get, + commands::adapters::chat_history_share_set, + commands::adapters::chat_history_delete, + // Subagent store + commands::adapters::subagent_identity_upsert, + commands::adapters::subagent_identity_list, + commands::adapters::subagent_run_save, + commands::adapters::subagent_run_list, + commands::adapters::subagent_run_load, + commands::adapters::subagent_run_prune, + commands::adapters::subagent_message_append, + commands::adapters::subagent_message_list, + // File system + commands::adapters::fs_read_text, + commands::adapters::fs_read_editable_text, + commands::adapters::fs_path_status, + commands::adapters::fs_read_image_source, + commands::adapters::fs_read_workspace_image, + commands::adapters::fs_write_text, + commands::adapters::fs_edit_text, + commands::adapters::fs_delete, + commands::adapters::fs_open_workspace_path, + commands::adapters::fs_create_dir, + commands::adapters::fs_rename, + commands::adapters::fs_roots, + commands::adapters::fs_list_dirs, + commands::adapters::fs_list, + commands::adapters::fs_glob, + commands::adapters::fs_grep, + commands::adapters::fs_mention_list, + commands::adapters::open_chat_file_link, + // Subagent worktrees + commands::adapters::subagent_worktree_create, + commands::adapters::subagent_worktree_status, + commands::adapters::subagent_worktree_apply, + commands::adapters::subagent_worktree_cleanup, + // MCP + commands::adapters::mcp_list_tools, + commands::adapters::mcp_call_tool, + commands::adapters::mcp_runtime_status, + commands::adapters::mcp_stop_server, + commands::adapters::mcp_test_server, + commands::adapters::mcp_restart_server, + // Memory + commands::adapters::memory_list, + commands::adapters::memory_read, + commands::adapters::memory_search, + commands::adapters::memory_write, + commands::adapters::memory_update, + commands::adapters::memory_delete, + commands::adapters::memory_delete_project, + commands::adapters::memory_accept, + commands::adapters::memory_apply_batch, + commands::adapters::memory_organize_run_create, + commands::adapters::memory_organize_run_update, + commands::adapters::memory_organize_run_list, + commands::adapters::memory_organize_run_read, + commands::adapters::memory_organize_run_clear_history, + commands::adapters::memory_organize_due_claim, + commands::adapters::memory_organize_due_complete, + commands::adapters::memory_index_overview, + commands::adapters::memory_paths_info, + commands::adapters::memory_recent_rejections, + commands::adapters::memory_today_local_date, + commands::adapters::memory_today_daily, + commands::adapters::memory_quota_summary, + commands::adapters::memory_wipe_all, + // Settings + commands::adapters::settings_load_all, + commands::adapters::settings_save_providers, + commands::adapters::settings_list_ccswitch_providers, + commands::adapters::settings_list_cherry_studio_providers, + commands::adapters::settings_list_cherry_studio_providers_from_path, + commands::adapters::settings_save_system, + commands::adapters::settings_save_mcp, + commands::adapters::settings_save_agents, + commands::adapters::settings_save_ssh, + commands::adapters::settings_apply_ssh_patch, + commands::adapters::settings_reset_ssh_known_host, + commands::adapters::settings_save_remote, + commands::adapters::settings_save_memory, + commands::adapters::app_update_check, + commands::adapters::app_update_install, + commands::adapters::app_restart, + commands::adapters::app_runtime_platform, + commands::adapters::app_set_close_window_behavior, + commands::adapters::app_set_global_shortcuts, + commands::adapters::app_window_pinned, + commands::adapters::app_toggle_window_pin, + commands::adapters::app_confirmed_exit, + commands::adapters::app_macos_traffic_light_metrics, + commands::adapters::app_tray_menu_sync, + // Hooks + commands::adapters::hook_run_script, + commands::adapters::hook_run_http_requests, + commands::adapters::hook_cancel_scope, + // Automation (cron tasks + hooks store) + commands::adapters::cron_validate_expression, + commands::adapters::automation_snapshot, + commands::adapters::automation_cron_apply, + commands::adapters::automation_hooks_apply, + commands::adapters::automation_list_runs, + commands::adapters::automation_clear_runs, + commands::adapters::automation_run_cron_now, + commands::adapters::automation_claim_prompt_runs, + commands::adapters::automation_release_prompt_run, + commands::adapters::automation_complete_prompt_run, + // Local command execution + commands::adapters::shell_run, + commands::adapters::runtime_cancel, + commands::adapters::managed_process_start, + commands::adapters::managed_process_status, + commands::adapters::managed_process_stop, + commands::adapters::managed_process_read_log, + commands::adapters::managed_process_snapshot, + commands::adapters::managed_process_clear, + commands::adapters::terminal_shell_options, + commands::adapters::terminal_list, + commands::adapters::terminal_create, + commands::adapters::terminal_create_ssh, + commands::adapters::terminal_answer_ssh_prompt, + commands::adapters::terminal_cancel_ssh_prompt, + commands::adapters::terminal_ssh_reconnect, + commands::adapters::terminal_ssh_latency, + commands::adapters::terminal_ssh_exec, + commands::adapters::terminal_ssh_local_forward_start, + commands::adapters::terminal_ssh_local_forward_list, + commands::adapters::terminal_ssh_local_forward_stop, + commands::adapters::terminal_ssh_local_forward_check_port, + commands::adapters::ssh_terminal_tabs_list, + commands::adapters::ssh_terminal_tab_open, + commands::adapters::ssh_terminal_tab_close, + commands::adapters::terminal_stream_attach, + commands::adapters::terminal_stream_input, + commands::adapters::terminal_stream_resize, + commands::adapters::terminal_rename, + commands::adapters::terminal_close, + commands::adapters::terminal_close_project, + commands::adapters::terminal_read_tail, + commands::adapters::sftp_list, + commands::adapters::sftp_stat, + commands::adapters::sftp_read_text, + commands::adapters::sftp_write_text, + commands::adapters::sftp_mkdir, + commands::adapters::sftp_rename, + commands::adapters::sftp_delete, + commands::adapters::sftp_transfer, + commands::adapters::sftp_cancel_transfer, + commands::adapters::sftp_transfer_status, + commands::adapters::git_status, + commands::adapters::git_discover_repositories, + commands::adapters::git_branches, + commands::adapters::git_init, + commands::adapters::git_clone_repository, + commands::adapters::git_clone_repository_start, + commands::adapters::git_clone_repository_tasks, + commands::adapters::git_clone_repository_cancel, + commands::adapters::git_clone_repository_dismiss, + commands::adapters::git_list_remote_branches, + commands::adapters::git_switch_branch, + commands::adapters::git_create_branch, + commands::adapters::git_diff, + commands::adapters::git_log, + commands::adapters::git_commit_details, + commands::adapters::git_compare_commit_with_remote, + commands::adapters::git_commit_diff, + commands::adapters::git_stage, + commands::adapters::git_stage_all, + commands::adapters::git_unstage, + commands::adapters::git_unstage_all, + commands::adapters::git_discard, + commands::adapters::git_discard_all, + commands::adapters::git_add_to_gitignore, + commands::adapters::git_open_system_file_location, + commands::adapters::git_commit, + commands::adapters::git_fetch, + commands::adapters::git_pull, + commands::adapters::git_set_remote, + commands::adapters::git_push, + commands::adapters::git_delete_branch, + commands::adapters::git_rename_branch, + commands::adapters::git_stash_push, + commands::adapters::git_stash_pop, + commands::adapters::system_pick_folder, + commands::adapters::system_pick_file, + commands::adapters::system_create_project_folder, + commands::adapters::system_import_pasted_texts, + commands::adapters::system_import_readable_file_paths, + commands::adapters::system_import_uploaded_readable_files, + commands::adapters::system_pick_readable_files, + commands::adapters::system_read_uploaded_image_preview, + commands::adapters::system_read_uploaded_native_attachment, + commands::adapters::system_list_skill_files, + commands::adapters::system_ensure_builtin_skills, + commands::adapters::system_read_skill_metadata, + commands::adapters::system_read_skill_text, + commands::adapters::system_manage_skill, + commands::adapters::system_append_debug_jsonl, + commands::adapters::system_begin_power_activity, + commands::adapters::system_end_power_activity, + commands::adapters::system_clipboard_read_text, + commands::adapters::gateway_connect, + commands::adapters::gateway_disconnect, + commands::adapters::gateway_status, + commands::adapters::gateway_nudge_connection, + commands::adapters::gateway_send_chat_ingress_batch, + commands::adapters::gateway_commit_chat_checkpoint, + commands::adapters::gateway_chat_claim_next, + commands::adapters::gateway_chat_mark_started, + commands::adapters::gateway_chat_mark_local_started, + commands::adapters::gateway_chat_mark_local_cancelled, + commands::adapters::gateway_chat_mark_queued_in_gui, + commands::adapters::gateway_chat_complete, + commands::adapters::gateway_chat_fail, + commands::adapters::gateway_chat_cancel_request, + commands::adapters::gateway_chat_heartbeat, + commands::adapters::gateway_chat_runtime_heartbeat, + commands::adapters::gateway_chat_release_lease, + commands::adapters::gateway_chat_queue_respond, + commands::adapters::gateway_publish_chat_queue_event, + commands::adapters::gateway_publish_settings_sync, + commands::adapters::gateway_tunnel_state, + commands::adapters::gateway_tunnel_create, + commands::adapters::gateway_tunnel_update, + commands::adapters::gateway_tunnel_close, + commands::adapters::gateway_tunnel_check, + commands::adapters::workspace_watch_set, + commands::adapters::provider_usage_query, + commands::adapters::provider_usage_test, + commands::adapters::proxy_get_server_info, + ] + }; +} + +fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + window.show()?; + window.unminimize()?; + window.set_focus()?; + } + + Ok(()) +} + +fn toggle_main_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let visible = window.is_visible().unwrap_or(false); + let focused = window.is_focused().unwrap_or(false); + if visible && focused { + let _ = window.hide(); + } else if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window from global shortcut: {error}"); + } + } +} + +pub(crate) fn toggle_main_window_pin(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let pin_state = app.state::>(); + let next = !pin_state.0.load(Ordering::SeqCst); + match window.set_always_on_top(next) { + Ok(()) => { + pin_state.0.store(next, Ordering::SeqCst); + if next { + if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window when pinning: {error}"); + } + } + let _ = app.emit("global-shortcut:pin-changed", next); + // 托盘勾选与置顶真源(WindowPinState)同步;托盘可能尚未建好。 + if let Some(handles) = app.try_state::>() { + handles.set_pin_checked(next); + } + } + Err(error) => eprintln!("failed to toggle LiveAgent window pin: {error}"), + } + } +} + +/// 应用级动作总线:全局快捷键与托盘菜单的动作都收敛到这里执行。 +/// Rust 能独立完成的直接做(webview 卡死时托盘仍可用);需要前端语义的 +/// 经 [`APP_ACTION_EVENT`] 转发(部分动作先呼出主窗口)。 +#[derive(Debug, Clone)] +enum AppAction { + Summon, + ToggleWindow, + TogglePin, + NewChat, + OpenConversation(String), + ViewAllConversations, + SwitchWorkspace(String), + StopRun(String), + StopAllRuns, + ToggleCronTask(String), + GatewayToggle, + SetTheme(&'static str), + OpenSettings, + CheckUpdates, + OpenDataDir, + Quit, +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AppActionEvent { + action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AppActionFeedbackEvent { + action: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + /// 结果附加值(如 cron 开关后的 "enabled"/"disabled")。 + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, +} + +/// 托盘菜单项 ID → 动作。静态 ID 与动态前缀都定义在 `services::tray`。 +fn tray_menu_action(id: &str) -> Option { + use services::tray as tray_ids; + match id { + tray_ids::TRAY_SHOW_ID => Some(AppAction::Summon), + tray_ids::TRAY_NEW_CHAT_ID => Some(AppAction::NewChat), + tray_ids::TRAY_PIN_ID => Some(AppAction::TogglePin), + tray_ids::TRAY_RECENT_VIEW_ALL_ID => Some(AppAction::ViewAllConversations), + tray_ids::TRAY_RUN_STOP_ALL_ID => Some(AppAction::StopAllRuns), + tray_ids::TRAY_GATEWAY_ID => Some(AppAction::GatewayToggle), + tray_ids::TRAY_THEME_LIGHT_ID => Some(AppAction::SetTheme("light")), + tray_ids::TRAY_THEME_DARK_ID => Some(AppAction::SetTheme("dark")), + tray_ids::TRAY_THEME_SYSTEM_ID => Some(AppAction::SetTheme("system")), + tray_ids::TRAY_SETTINGS_ID => Some(AppAction::OpenSettings), + tray_ids::TRAY_CHECK_UPDATES_ID => Some(AppAction::CheckUpdates), + tray_ids::TRAY_OPEN_DATA_DIR_ID => Some(AppAction::OpenDataDir), + tray_ids::TRAY_QUIT_ID => Some(AppAction::Quit), + _ => { + if let Some(rest) = id.strip_prefix(tray_ids::TRAY_RECENT_PREFIX) { + Some(AppAction::OpenConversation(rest.to_string())) + } else if let Some(rest) = id.strip_prefix(tray_ids::TRAY_WORKSPACE_PREFIX) { + Some(AppAction::SwitchWorkspace(rest.to_string())) + } else if let Some(rest) = id.strip_prefix(tray_ids::TRAY_RUN_PREFIX) { + Some(AppAction::StopRun(rest.to_string())) + } else { + id.strip_prefix(tray_ids::TRAY_CRON_PREFIX) + .map(|rest| AppAction::ToggleCronTask(rest.to_string())) + } + } + } +} + +/// 转发前端动作。`show_window` 用于用户预期看到界面反馈的动作 +/// (开会话/新建对话/打开设置等);后台型动作(停止运行/改主题/网关开关) +/// 不抢焦点。 +fn forward_app_action( + app: &tauri::AppHandle, + action: &'static str, + id: Option, + value: Option, + show_window: bool, +) { + if show_window { + if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window for action {action}: {error}"); + } + } + if let Err(error) = app.emit(APP_ACTION_EVENT, AppActionEvent { action, id, value }) { + eprintln!("failed to emit app action {action}: {error}"); + } +} + +fn dispatch_app_action(app: &tauri::AppHandle, action: AppAction) { + match action { + AppAction::Summon => { + if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window: {error}"); + } + } + AppAction::ToggleWindow => toggle_main_window(app), + AppAction::TogglePin => toggle_main_window_pin(app), + AppAction::NewChat => forward_app_action(app, "new-chat", None, None, true), + AppAction::OpenConversation(id) => { + forward_app_action(app, "open-conversation", Some(id), None, true); + } + AppAction::ViewAllConversations => { + forward_app_action(app, "view-all-conversations", None, None, true); + } + AppAction::SwitchWorkspace(id) => { + forward_app_action(app, "switch-workspace", Some(id), None, true); + } + AppAction::StopRun(id) => forward_app_action(app, "stop-run", Some(id), None, false), + AppAction::StopAllRuns => forward_app_action(app, "stop-all-runs", None, None, false), + AppAction::GatewayToggle => forward_app_action(app, "gateway-toggle", None, None, false), + AppAction::SetTheme(theme) => { + forward_app_action(app, "set-theme", None, Some(theme.to_string()), false); + } + AppAction::OpenSettings => forward_app_action(app, "open-settings", None, None, true), + AppAction::CheckUpdates => forward_app_action(app, "check-updates", None, None, true), + AppAction::ToggleCronTask(task_id) => { + // 托盘的定时任务子项是启用开关:翻转走 AutomationStore 唯一的 + // cron_apply 写路径(CAS),成功后 automation:cron-changed 会驱动 + // 前端 store 与托盘勾选自然刷新。开关是后台动作,不呼出主窗口; + // 结果经 feedback 事件给前端 toast(窗口可见时提示文案)。 + let Some(store) = app.try_state::>() else { + return; + }; + let store = Arc::clone(store.inner()); + let app_handle = app.clone(); + crate::compat::async_runtime::spawn_blocking(move || { + let (value, error) = match store.toggle_cron_task_enabled(&task_id) { + Ok(enabled) => ( + Some(if enabled { "enabled" } else { "disabled" }.to_string()), + None, + ), + Err(error) => { + eprintln!("failed to toggle cron task from tray: {error}"); + (None, Some(error)) + } + }; + if let Err(emit_error) = app_handle.emit( + APP_ACTION_FEEDBACK_EVENT, + AppActionFeedbackEvent { + action: "toggle-cron-task", + id: Some(task_id), + ok: error.is_none(), + error, + value, + }, + ) { + eprintln!("failed to emit cron toggle feedback: {emit_error}"); + } + }); + } + AppAction::OpenDataDir => { + use tauri_plugin_opener::OpenerExt; + match commands::settings::config_dir() { + Ok(dir) => { + if let Err(error) = app + .opener() + .open_path(dir.to_string_lossy().to_string(), None::<&str>) + { + eprintln!("failed to open LiveAgent data directory: {error}"); + } + } + Err(error) => eprintln!("failed to resolve LiveAgent data directory: {error}"), + } + } + AppAction::Quit => { + let allow_exit = app.state::>(); + let terminal_registry = app.state::>(); + request_app_exit(app, allow_exit.inner(), terminal_registry.inner()); + } + } +} + +fn handle_global_shortcut( + app: &tauri::AppHandle, + shortcut: &tauri_plugin_global_shortcut::Shortcut, +) { + let action = app + .state::>() + .lookup_action(shortcut); + let Some(action) = action else { + return; + }; + let action = match action.as_str() { + "summon" => AppAction::Summon, + "toggle" => AppAction::ToggleWindow, + "newChat" => AppAction::NewChat, + "pin" => AppAction::TogglePin, + _ => return, + }; + dispatch_app_action(app, action); +} + +fn request_app_exit( + app: &tauri::AppHandle, + allow_exit: &AtomicBool, + terminal_registry: &runtime::terminal::TerminalSessionRegistry, +) { + let running_count = terminal_registry.running_session_count(); + if running_count > 0 { + if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window before terminal exit confirm: {error}"); + } + if let Err(error) = app.emit( + TERMINAL_EXIT_REQUESTED_EVENT, + TerminalExitRequestedEvent { running_count }, + ) { + eprintln!("failed to request terminal exit confirmation: {error}"); + } + return; + } + + allow_exit.store(true, Ordering::SeqCst); + app.exit(0); +} + +fn configure_system_tray(app: &tauri::App) -> tauri::Result<()> { + let skeleton = services::tray::build_tray_menu_skeleton(app, crate::app_version())?; + let menu = skeleton.menu.clone(); + + let mut tray_builder = TrayIconBuilder::new() + .tooltip("LiveAgent") + .menu(&menu) + .show_menu_on_left_click(TRAY_SHOW_MENU_ON_LEFT_CLICK) + .on_menu_event(|app, event| { + if let Some(action) = tray_menu_action(event.id().as_ref()) { + dispatch_app_action(app, action); + } + }) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } => { + if let Err(error) = show_main_window(tray.app_handle()) { + eprintln!("failed to show LiveAgent window from tray double-click: {error}"); + } + } + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Down, + .. + } => { + // Windows 惯例:左键单击即激活主窗口(菜单在右键)。 + // 其他平台左键弹菜单(TRAY_SHOW_MENU_ON_LEFT_CLICK)。 + if cfg!(target_os = "windows") { + if let Err(error) = show_main_window(tray.app_handle()) { + eprintln!("failed to show LiveAgent window from tray click: {error}"); + } + } + } + _ => {} + }); + + #[cfg(target_os = "macos")] + { + match tauri::image::Image::from_bytes(include_bytes!("../icons/tray-icon-macos.png")) { + Ok(icon) => { + tray_builder = tray_builder.icon(icon).icon_as_template(true); + } + Err(error) => { + eprintln!("failed to load macOS tray icon: {error}"); + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + } + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + } + + let tray = tray_builder.build(app)?; + let handles = Arc::new(services::tray::TrayMenuHandles::new( + skeleton, + tray.clone(), + crate::app_version(), + )); + app.manage(tray); + app.manage(handles); + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn configure_windows_window_chrome(app: &tauri::App) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + window.set_decorations(false)?; + } + + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let app = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_mcp_bridge::init()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags(WINDOW_STATE_FLAGS) + .build(), + ) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, shortcut, event| { + if event.state() == tauri_plugin_global_shortcut::ShortcutState::Pressed { + handle_global_shortcut(app, shortcut); + } + }) + .build(), + ) + // 纯桌面态:与 AppContext 无关的静态状态(headless 无窗口/托盘/快捷键)。 + .manage(Arc::new(commands::app::GlobalShortcutRegistry::default())) + .manage(Arc::new(commands::app::WindowPinState::default())) + .manage(Arc::new(commands::mcp::McpRuntimeManager::default())) + .manage(Arc::new(runtime::shell_runner::ShellRunRegistry::default())) + .manage(Arc::new(commands::hook::HookScopeRegistry::default())) + .setup({ + move |app| { + commands::history_db::initialize_history_db()?; + configure_system_tray(app)?; + #[cfg(target_os = "windows")] + configure_windows_window_chrome(app)?; + if let Err(error) = commands::settings::initialize_system_proxy_from_db() { + eprintln!("failed to initialize system proxy state: {error}"); + } + commands::system::gc_upload_staging_on_startup(); + app.manage(services::proxy::start_proxy_server()?); + if let Err(error) = services::skills::ensure_builtin_agent_skills_sync() { + eprintln!("failed to seed builtin skills: {error}"); + } + // 业务装配(headless 与 desktop 共用):状态创建 + 依赖注入 + 后台任务。 + let event_emitter: Arc = + crate::events::shared_emitter(app.handle().clone()); + let ctx = crate::app_context::AppContext::new(event_emitter); + manage_app_context_states(app, &ctx); + Ok(()) + } + }) + .on_window_event(|window, event| { + if window.label() != MAIN_WINDOW_LABEL { + return; + } + + if let WindowEvent::CloseRequested { api, .. } = event { + let Some(ctx) = window.try_state::>() else { + return; + }; + api.prevent_close(); + if commands::app::is_close_window_exit(&ctx.close_window_behavior) { + request_app_exit(window.app_handle(), &ctx.allow_exit, &ctx.terminal_registry); + } else if let Err(error) = window.hide() { + eprintln!("failed to hide LiveAgent window on close: {error}"); + } + } + }) + .invoke_handler(app_invoke_handler!()) + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(move |app, event| match event { + tauri::RunEvent::Resumed => { + if let Some(ctx) = app.try_state::>() { + if let Err(error) = ctx.gateway_controller.nudge_connection("app_resumed", true) { + eprintln!("failed to nudge gateway connection after app resume: {error}"); + } + } + } + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + if let Err(error) = show_main_window(app) { + eprintln!("failed to show LiveAgent window from dock reopen: {error}"); + } + } + tauri::RunEvent::ExitRequested { api, .. } => { + let Some(ctx) = app.try_state::>() else { + api.prevent_exit(); + return; + }; + if !ctx.allow_exit.load(Ordering::SeqCst) { + let running_count = ctx.terminal_registry.running_session_count(); + if running_count > 0 { + if let Err(error) = show_main_window(app) { + eprintln!( + "failed to show LiveAgent window before terminal exit confirm: {error}" + ); + } + if let Err(error) = app.emit( + TERMINAL_EXIT_REQUESTED_EVENT, + TerminalExitRequestedEvent { running_count }, + ) { + eprintln!("failed to request terminal exit confirmation: {error}"); + } + } + api.prevent_exit(); + } else { + // Real exit: reclaim every non-isolated managed process + // before the OS tears us down (Drop is not guaranteed). + ctx.terminal_registry.shutdown_cleanup(); + ctx.managed_process_registry.shutdown_cleanup(); + ctx.git_clone_task_registry.shutdown_cleanup(); + ctx.power_activity.clear_all(); + } + } + _ => {} + }); +} + +/// 将 `AppContext` 的各字段注册为 tauri `State`,供命令适配层按需解包。 +/// 注意:`State` 以类型区分,字段本身必须各自 `manage`(不能只 manage 整个 ctx)。 +fn manage_app_context_states(app: &tauri::App, ctx: &Arc) { + app.manage(Arc::clone(&ctx.automation_store)); + app.manage(Arc::clone(&ctx.automation_scheduler)); + app.manage(Arc::clone(&ctx.memory_store)); + app.manage(Arc::clone(&ctx.provider_usage_service)); + app.manage(Arc::clone(&ctx.power_activity)); + app.manage(Arc::clone(&ctx.managed_process_registry)); + app.manage(Arc::clone(&ctx.terminal_registry)); + app.manage(Arc::clone(&ctx.sftp_registry)); + app.manage(Arc::clone(&ctx.git_clone_task_registry)); + app.manage(Arc::clone(&ctx.allow_exit)); + app.manage(Arc::clone(&ctx.close_window_behavior)); + app.manage(Arc::clone(&ctx.gateway_controller)); +} diff --git a/crates/agent-gui/src-tauri/src/events.rs b/crates/agent-gui/src-tauri/src/events.rs new file mode 100644 index 000000000..9d64c8122 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/events.rs @@ -0,0 +1,109 @@ +//! Event emission abstraction. +//! +//! Business services previously held a `tauri::AppHandle` solely to call +//! `app_handle.emit(...)`. That couples them to Tauri. This module defines a +//! minimal, object-safe `EventEmitter` trait so the same code can run in both +//! the desktop build (emits through the Tauri event system) and the headless +//! build (emits through a WebSocket broadcast, implemented in P1.2). +//! +//! The trait itself is generic-free (dyn-compatible); the ergonomic generic +//! [`EventEmitterExt::emit`] is provided as a blanket extension. + +#[cfg(feature = "desktop")] +use std::sync::Arc; + +use serde::Serialize; + +/// A sink for frontend events. All events are one-way (fire-and-forget); +/// there is no listen side in Rust. +pub trait EventEmitter: Send + Sync { + /// Emit a pre-serialized `event` payload to the frontend. Errors are + /// surfaced as strings to keep the trait free of framework types. + fn emit_json(&self, event: &str, payload: serde_json::Value) -> Result<(), String>; +} + +/// Ergonomic generic wrapper over [`EventEmitter::emit_json`], mirroring the +/// `tauri::Emitter::emit` call sites (`emitter.emit(EVENT, payload)`). +pub trait EventEmitterExt { + fn emit(&self, event: &str, payload: S) -> Result<(), String>; +} + +impl EventEmitterExt for T { + fn emit(&self, event: &str, payload: S) -> Result<(), String> { + let value = serde_json::to_value(payload).map_err(|e| format!("serialize payload: {e}"))?; + self.emit_json(event, value) + } +} + +/// Desktop implementation: forwards events to the Tauri event system. +#[cfg(feature = "desktop")] +pub struct TauriEventEmitter { + app_handle: tauri::AppHandle, +} + +#[cfg(feature = "desktop")] +impl TauriEventEmitter { + pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { app_handle } + } +} + +#[cfg(feature = "desktop")] +impl EventEmitter for TauriEventEmitter { + fn emit_json(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { + use tauri::Emitter; + self.app_handle + .emit(event, payload) + .map_err(|error| error.to_string()) + } +} + +/// Headless implementation: emits through a WebSocket broadcast channel. +/// Wired up by `src/headless.rs`, which keeps one `Arc` for +/// the `EventEmitter` injection and clones it into the axum state so the +/// `/ws` route can subscribe to the same broadcast. +#[cfg(not(feature = "desktop"))] +#[derive(Clone)] +pub struct WsEventEmitter { + tx: tokio::sync::broadcast::Sender, +} + +/// A single frontend event serialized for WebSocket delivery. +#[cfg(not(feature = "desktop"))] +#[derive(Clone, Serialize)] +pub struct WsEvent { + pub event: String, + pub payload: serde_json::Value, +} + +#[cfg(not(feature = "desktop"))] +impl WsEventEmitter { + pub fn new(tx: tokio::sync::broadcast::Sender) -> Self { + Self { tx } + } + + /// Subscribe to the event stream (used by the `/ws` route). + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.tx.subscribe() + } +} + +#[cfg(not(feature = "desktop"))] +impl EventEmitter for WsEventEmitter { + fn emit_json(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { + let _ = self.tx.send(WsEvent { + event: event.to_string(), + payload, + }); + Ok(()) + } +} + +/// Helper to build the shared emitter used across services. On desktop it +/// wraps the app handle; on headless, `src/headless.rs` constructs the +/// `WsEventEmitter` directly so it can also hand the broadcast sender to the +/// `/ws` route. +#[cfg(feature = "desktop")] +pub fn shared_emitter(app_handle: tauri::AppHandle) -> Arc { + Arc::new(TauriEventEmitter::new(app_handle)) +} diff --git a/crates/agent-gui/src-tauri/src/headless.rs b/crates/agent-gui/src-tauri/src/headless.rs new file mode 100644 index 000000000..a012ca02f --- /dev/null +++ b/crates/agent-gui/src-tauri/src/headless.rs @@ -0,0 +1,2682 @@ +//! Headless runtime (no Tauri): an axum HTTP/WebSocket server that +//! exposes the same business command surface the desktop build exposes +//! via `#[tauri::command]`. Compiled only when the `desktop` feature is +//! off (`--no-default-features`). +//! +//! Routes: +//! GET /health -> { ok, version, mode } +//! GET /api/status -> gateway status snapshot +//! POST /api/invoke -> { cmd, args } -> { ok, value | error } +//! GET /ws -> WebSocket broadcast of frontend events +//! GET /* -> WebUI static assets (SPA fallback) +//! +//! The invoke dispatch below is checked against the committed command +//! manifest (scripts/manifest/commands.json) by scripts/verify_headless.py — +//! it is hand-maintained (the generator does not overwrite this file) and +//! verified both ways in CI. See README "Headless Command Registry & Generator". +#![cfg(not(feature = "desktop"))] + +use std::collections::HashMap; +#[cfg(feature = "runtime-fallback")] +use std::path::PathBuf; +use std::sync::Arc; +use std::net::SocketAddr; +use std::time::Instant; + +use dirs; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::multipart::Multipart; +use axum::extract::{ConnectInfo, DefaultBodyLimit, Extension, FromRef, Path as AxumPath, Query, State, State as AxumState}; +use axum::http::{header, Method, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, get, post}; +use axum::{Json, Router}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::Value; +use tokio::sync::broadcast; + +use crate::commands::chat_history::{ChatHistoryMessageRef, ChatHistorySearchArgs, ChatHistorySegmentMutationInput, ChatHistoryUpsertInput}; +use crate::commands::mcp::{McpServerConfig}; +use crate::commands::subagent_store::{SubagentIdentityListInput, SubagentIdentityUpsertInput, SubagentMessageAppendInput, SubagentMessageListInput, SubagentRunListInput, SubagentRunLoadInput, SubagentRunPruneInput, SubagentRunSaveInput}; +use crate::commands::subagent_worktree::{SubagentWorktreeApplyInput, SubagentWorktreeCleanupInput, SubagentWorktreeCreateInput, SubagentWorktreeStatusInput}; +use crate::commands::system::{SystemPastedTextInput, SystemUploadedReadableFileInput}; +use crate::runtime::task_runner::{HttpRequestInput}; +use crate::services::automation::{AutomationApplyInput, CompletePromptRunInput}; +use crate::services::gateway::{GatewayChatQueueEventInput, GatewayChatQueueResponseInput}; +use crate::services::gateway::chat_ingress::{GatewayChatCheckpointInput, GatewayChatIngressBatchInput}; +use crate::services::memory::{MemoryAcceptArgs, MemoryBatchArgs, MemoryDeleteArgs, MemoryDeleteProjectArgs, MemoryListArgs, MemoryOrganizeDueClaimArgs, MemoryOrganizeRunCreateArgs, MemoryOrganizeRunListArgs, MemoryOrganizeRunReadArgs, MemoryOrganizeRunUpdateArgs, MemoryQuotaSummaryArgs, MemoryReadArgs, MemoryRecentRejectionsArgs, MemorySearchArgs, MemoryUpdateArgs, MemoryWriteArgs}; +use crate::services::tunnel::{GatewayTunnelCreateInput, GatewayTunnelUpdateInput}; + +use crate::app_context::AppContext; +use crate::events::WsEventEmitter; +use crate::runtime::shell_runner::ShellRunRegistry; +use crate::services::proxy::{handle_image_proxy, handle_proxy, ProxyServerState}; + +// ---- Unified error type for headless command dispatch ---- + +#[derive(Debug)] +pub enum HeadlessError { + /// Command only available in the desktop build (requires AppHandle / Window). + DesktopOnly(&'static str), + /// Feature unavailable in headless (e.g. native file picker). + Unavailable(&'static str), + /// Business-logic error forwarded from the underlying command. + Business(String), +} + +impl std::fmt::Display for HeadlessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HeadlessError::DesktopOnly(cmd) => write!(f, "command `{cmd}` is only available in desktop mode"), + HeadlessError::Unavailable(what) => write!(f, "{what} is unavailable in headless mode"), + HeadlessError::Business(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for HeadlessError {} + +impl From for HeadlessError { + fn from(s: String) -> Self { HeadlessError::Business(s) } +} + +// ---- Shared headless state ---- + +#[derive(Clone)] +pub struct HeadlessState { + pub ctx: Arc, + pub emitter: Arc, + pub mcp_runtime: Arc, + pub shell_runs: Arc, + pub hook_scopes: Arc, + pub proxy_server: Arc, + /// BFF 模式下反代路由挂在主 HTTP 服务上,前端拿到的反代 baseUrl 就是主服务地址。 + pub proxy_base_url: String, + /// Optional Bearer token for /api/invoke and non-same-origin /ws (LIVEAGENT_API_TOKEN). + pub api_token: Option, +} + +impl FromRef for Arc { + fn from_ref(state: &HeadlessState) -> Self { + state.proxy_server.clone() + } +} + +// ---- Argument helpers ---- + +fn camelize(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper = false; + for ch in name.chars() { + if ch == '_' { upper = true; } + else if upper { out.extend(ch.to_uppercase()); upper = false; } + else { out.push(ch); } + } + out +} + +fn remove_arg(obj: &mut serde_json::Map, name: &str) -> Option { + if let Some(v) = obj.remove(name) { return Some(v); } + let camel = camelize(name); + if camel != name { obj.remove(&camel) } else { None } +} + +fn take_arg(args: &mut Value, name: &str) -> Result { + let obj = args.as_object_mut().ok_or_else(|| HeadlessError::Business("args must be a JSON object".into()))?; + let value = remove_arg(obj, name).ok_or_else(|| HeadlessError::Business(format!("missing argument `{name}`")))?; + serde_json::from_value(value).map_err(|e| HeadlessError::Business(format!("argument `{name}`: {e}"))) +} + +fn take_arg_opt(args: &mut Value, name: &str) -> Result, HeadlessError> { + let obj = args.as_object_mut().ok_or_else(|| HeadlessError::Business("args must be a JSON object".into()))?; + match remove_arg(obj, name) { + None | Some(Value::Null) => Ok(None), + Some(value) => serde_json::from_value(value).map(Some) + .map_err(|e| HeadlessError::Business(format!("argument `{name}`: {e}"))), + } +} + +fn to_value(v: T) -> Result { + serde_json::to_value(v).map_err(|e| HeadlessError::Business(format!("serialize result: {e}"))) +} + +// ---- Command dispatch (manifest-verified, see scripts/verify_headless.py) ---- + +pub async fn dispatch(state: &HeadlessState, cmd: &str, args: Value) -> Result { + let mut args = args; + match cmd { + // ===== app ===== + "app_window_pinned" => Err(HeadlessError::DesktopOnly("app_window_pinned")), + "app_toggle_window_pin" => Err(HeadlessError::DesktopOnly("app_toggle_window_pin")), + "app_set_global_shortcuts" => Err(HeadlessError::DesktopOnly("app_set_global_shortcuts")), + "app_runtime_platform" => { + to_value(crate::commands::app::app_runtime_platform()) + }, + "app_set_close_window_behavior" => { + let behavior_v: String = take_arg(&mut args, "behavior")?; + match crate::commands::app::app_set_close_window_behavior(behavior_v, &state.ctx.close_window_behavior) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "app_confirmed_exit" => Err(HeadlessError::DesktopOnly("app_confirmed_exit")), + "app_macos_traffic_light_metrics" => Err(HeadlessError::DesktopOnly("app_macos_traffic_light_metrics")), + // ===== tray ===== + "app_tray_menu_sync" => Err(HeadlessError::DesktopOnly("app_tray_menu_sync")), + // ===== update ===== + "app_update_check" => Err(HeadlessError::DesktopOnly("app_update_check")), + "app_update_install" => Err(HeadlessError::DesktopOnly("app_update_install")), + "app_restart" => Err(HeadlessError::DesktopOnly("app_restart")), + // ===== system ===== + "system_pick_folder" => { + let path_v: Option = take_arg_opt(&mut args, "path")?; + let initial_v: Option = take_arg_opt(&mut args, "initial_workdir")?; + let target = path_v + .or(initial_v) + .unwrap_or_else(|| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/".to_string())); + let p = std::path::Path::new(&target); + if p.is_dir() { + to_value(target) + } else { + Err(HeadlessError::Business(format!("路径不存在或不是目录: {target}"))) + } + }, + "system_pick_file" => Err(HeadlessError::Unavailable("native file picker")), + "system_create_project_folder" => { + let parent_v: String = take_arg(&mut args, "parent")?; + let name_v: String = take_arg(&mut args, "name")?; + match crate::commands::system::system_create_project_folder(parent_v, name_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_pick_readable_files" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let max_files_v: Option = take_arg_opt(&mut args, "max_files")?; + match crate::commands::system::system_pick_readable_files(workdir_v, max_files_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_import_readable_file_paths" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let paths_v: Vec = take_arg(&mut args, "paths")?; + let max_files_v: Option = take_arg_opt(&mut args, "max_files")?; + match crate::commands::system::system_import_readable_file_paths(workdir_v, paths_v, max_files_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_import_uploaded_readable_files" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let files_v: Vec = take_arg(&mut args, "files")?; + let max_files_v: Option = take_arg_opt(&mut args, "max_files")?; + match crate::commands::system::system_import_uploaded_readable_files(workdir_v, files_v, max_files_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_import_pasted_texts" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let texts_v: Vec = take_arg(&mut args, "texts")?; + match crate::commands::system::system_import_pasted_texts(workdir_v, texts_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_read_uploaded_image_preview" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let absolute_path_v: String = take_arg(&mut args, "absolute_path")?; + match crate::commands::system::system_read_uploaded_image_preview(workdir_v, absolute_path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_read_uploaded_native_attachment" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let absolute_path_v: Option = take_arg_opt(&mut args, "absolute_path")?; + let kind_v: Option = take_arg_opt(&mut args, "kind")?; + match crate::commands::system::system_read_uploaded_native_attachment(workdir_v, absolute_path_v, kind_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_list_skill_files" => { + match crate::commands::system::system_list_skill_files().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_ensure_builtin_skills" => { + match crate::commands::system::system_ensure_builtin_skills().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_manage_skill" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::system::system_manage_skill(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_read_skill_text" => { + let path_v: String = take_arg(&mut args, "path")?; + let offset_v: Option = take_arg_opt(&mut args, "offset")?; + let length_v: Option = take_arg_opt(&mut args, "length")?; + match crate::commands::system::system_read_skill_text(path_v, offset_v, length_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_read_skill_metadata" => { + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::system::system_read_skill_metadata(path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_append_debug_jsonl" => { + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let entry_v: Value = take_arg(&mut args, "entry")?; + match crate::commands::system::system_append_debug_jsonl(conversation_id_v, entry_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_clipboard_read_text" => { + match crate::commands::system::system_clipboard_read_text().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_begin_power_activity" => { + let activity_id_v: String = take_arg(&mut args, "activity_id")?; + let reason_v: String = take_arg(&mut args, "reason")?; + let ttl_ms_v: Option = take_arg_opt(&mut args, "ttl_ms")?; + match crate::commands::system::system_begin_power_activity(activity_id_v, reason_v, ttl_ms_v, &state.ctx.power_activity) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "system_end_power_activity" => { + let activity_id_v: String = take_arg(&mut args, "activity_id")?; + match crate::commands::system::system_end_power_activity(activity_id_v, &state.ctx.power_activity) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== cron ===== + "cron_validate_expression" => { + let expression_v: String = take_arg(&mut args, "expression")?; + match crate::commands::cron::cron_validate_expression(expression_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_snapshot" => { + match crate::commands::cron::automation_snapshot(&state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_cron_apply" => { + let input_v: AutomationApplyInput = take_arg(&mut args, "input")?; + match crate::commands::cron::automation_cron_apply(input_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_hooks_apply" => { + let input_v: AutomationApplyInput = take_arg(&mut args, "input")?; + match crate::commands::cron::automation_hooks_apply(input_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_list_runs" => { + let task_id_v: String = take_arg(&mut args, "task_id")?; + let limit_v: Option = take_arg_opt(&mut args, "limit")?; + match crate::commands::cron::automation_list_runs(task_id_v, limit_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_clear_runs" => { + let task_id_v: String = take_arg(&mut args, "task_id")?; + match crate::commands::cron::automation_clear_runs(task_id_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_run_cron_now" => { + let task_id_v: String = take_arg(&mut args, "task_id")?; + match crate::commands::cron::automation_run_cron_now(task_id_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_claim_prompt_runs" => { + match crate::commands::cron::automation_claim_prompt_runs(&state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_release_prompt_run" => { + let execution_id_v: String = take_arg(&mut args, "execution_id")?; + match crate::commands::cron::automation_release_prompt_run(execution_id_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "automation_complete_prompt_run" => { + let input_v: CompletePromptRunInput = take_arg(&mut args, "input")?; + match crate::commands::cron::automation_complete_prompt_run(input_v, &state.ctx.automation_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== hook ===== + "hook_run_script" => { + let workdir_v: Option = take_arg_opt(&mut args, "workdir")?; + let script_v: String = take_arg(&mut args, "script")?; + let timeout_ms_v: Option = take_arg_opt(&mut args, "timeout_ms")?; + let scope_id_v: Option = take_arg_opt(&mut args, "scope_id")?; + let context_v: Option> = take_arg_opt(&mut args, "context")?; + match crate::commands::hook::hook_run_script(workdir_v, script_v, timeout_ms_v, scope_id_v, context_v, &state.hook_scopes).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "hook_run_http_requests" => { + let requests_v: Vec = take_arg(&mut args, "requests")?; + let scope_id_v: Option = take_arg_opt(&mut args, "scope_id")?; + match crate::commands::hook::hook_run_http_requests(requests_v, scope_id_v, &state.hook_scopes).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "hook_cancel_scope" => { + let scope_id_v: String = take_arg(&mut args, "scope_id")?; + match crate::commands::hook::hook_cancel_scope(scope_id_v, &state.hook_scopes).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== settings ===== + "settings_list_ccswitch_providers" => { + match crate::commands::settings::settings_list_ccswitch_providers().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_list_cherry_studio_providers" => { + match crate::commands::settings::settings_list_cherry_studio_providers().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_list_cherry_studio_providers_from_path" => { + let data_path_v: String = take_arg(&mut args, "data_path")?; + match crate::commands::settings::settings_list_cherry_studio_providers_from_path(data_path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_load_all" => { + match crate::commands::settings::settings_load_all().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_providers" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_providers(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_system" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_system(payload_v, &state.ctx.automation_scheduler).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_mcp" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_mcp(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_remote" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_remote(payload_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_memory" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_memory(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_agents" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_agents(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_save_ssh" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_save_ssh(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_apply_ssh_patch" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::settings::settings_apply_ssh_patch(payload_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "settings_reset_ssh_known_host" => { + let host_v: String = take_arg(&mut args, "host")?; + let port_v: u16 = take_arg(&mut args, "port")?; + match crate::commands::settings::settings_reset_ssh_known_host(host_v, port_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== subagent_store ===== + "subagent_identity_upsert" => { + let input_v: SubagentIdentityUpsertInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_identity_upsert(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_identity_list" => { + let input_v: SubagentIdentityListInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_identity_list(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_run_save" => { + let input_v: SubagentRunSaveInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_run_save(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_run_list" => { + let input_v: SubagentRunListInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_run_list(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_run_load" => { + let input_v: SubagentRunLoadInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_run_load(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_run_prune" => { + let input_v: SubagentRunPruneInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_run_prune(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_message_append" => { + let input_v: SubagentMessageAppendInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_message_append(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_message_list" => { + let input_v: SubagentMessageListInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_store::subagent_message_list(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== chat_history ===== + "chat_history_branch" => { + let id_v: String = take_arg(&mut args, "id")?; + let base_message_ref_v: ChatHistoryMessageRef = take_arg(&mut args, "base_message_ref")?; + match crate::commands::chat_history::chat_history_branch(id_v, base_message_ref_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_list" => { + let page_v: i64 = take_arg(&mut args, "page")?; + let page_size_v: i64 = take_arg(&mut args, "page_size")?; + let cwd_v: Option = take_arg_opt(&mut args, "cwd")?; + let cwd_empty_v: Option = take_arg_opt(&mut args, "cwd_empty")?; + match crate::commands::chat_history::chat_history_list(page_v, page_size_v, cwd_v, cwd_empty_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_workdirs" => { + match crate::commands::chat_history::chat_history_workdirs().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_shared_list" => { + let page_v: i64 = take_arg(&mut args, "page")?; + let page_size_v: i64 = take_arg(&mut args, "page_size")?; + match crate::commands::chat_history::chat_history_shared_list(page_v, page_size_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_search" => { + let args_v: ChatHistorySearchArgs = take_arg(&mut args, "args")?; + match crate::commands::chat_history::chat_history_search(args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_get_window" => { + let id_v: String = take_arg(&mut args, "id")?; + let max_messages_v: i64 = take_arg(&mut args, "max_messages")?; + let before_offset_v: Option = take_arg_opt(&mut args, "before_offset")?; + let expected_revision_v: Option = take_arg_opt(&mut args, "expected_revision")?; + let include_active_segment_v: bool = take_arg(&mut args, "include_active_segment")?; + match crate::commands::chat_history::chat_history_get_window(id_v, max_messages_v, before_offset_v, expected_revision_v, include_active_segment_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_upsert" => { + let input_v: ChatHistoryUpsertInput = take_arg(&mut args, "input")?; + match crate::commands::chat_history::chat_history_upsert(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_upsert_active_segment" => { + let input_v: ChatHistorySegmentMutationInput = take_arg(&mut args, "input")?; + match crate::commands::chat_history::chat_history_upsert_active_segment(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_append_segment" => { + let input_v: ChatHistorySegmentMutationInput = take_arg(&mut args, "input")?; + match crate::commands::chat_history::chat_history_append_segment(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_rename" => { + let id_v: String = take_arg(&mut args, "id")?; + let title_v: String = take_arg(&mut args, "title")?; + match crate::commands::chat_history::chat_history_rename(id_v, title_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_set_pinned" => { + let id_v: String = take_arg(&mut args, "id")?; + let is_pinned_v: bool = take_arg(&mut args, "is_pinned")?; + match crate::commands::chat_history::chat_history_set_pinned(id_v, is_pinned_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_set_model" => { + let id_v: String = take_arg(&mut args, "id")?; + let selected_model_json_v: String = take_arg(&mut args, "selected_model_json")?; + match crate::commands::chat_history::chat_history_set_model(id_v, selected_model_json_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_share_get" => { + let id_v: String = take_arg(&mut args, "id")?; + match crate::commands::chat_history::chat_history_share_get(id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_share_set" => { + let id_v: String = take_arg(&mut args, "id")?; + let enabled_v: bool = take_arg(&mut args, "enabled")?; + let redact_tool_content_v: Option = take_arg_opt(&mut args, "redact_tool_content")?; + match crate::commands::chat_history::chat_history_share_set(id_v, enabled_v, redact_tool_content_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_delete" => { + let id_v: String = take_arg(&mut args, "id")?; + match crate::commands::chat_history::chat_history_delete(id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "chat_history_replace_from_message" => { + let id_v: String = take_arg(&mut args, "id")?; + let base_message_ref_v: ChatHistoryMessageRef = take_arg(&mut args, "base_message_ref")?; + let replacement_message_v: Value = take_arg(&mut args, "replacement_message")?; + let max_messages_v: i64 = take_arg(&mut args, "max_messages")?; + let expected_revision_v: String = take_arg(&mut args, "expected_revision")?; + match crate::commands::chat_history::chat_history_replace_from_message(id_v, base_message_ref_v, replacement_message_v, max_messages_v, expected_revision_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== gateway ===== + "provider_usage_query" => { + let provider_id_v: String = take_arg(&mut args, "provider_id")?; + let refresh_v: bool = take_arg(&mut args, "refresh")?; + match crate::commands::gateway::provider_usage_query(provider_id_v, refresh_v, &state.ctx.provider_usage_service).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "provider_usage_test" => { + let provider_id_v: String = take_arg(&mut args, "provider_id")?; + let config_json_v: String = take_arg(&mut args, "config_json")?; + match crate::commands::gateway::provider_usage_test(provider_id_v, config_json_v, &state.ctx.provider_usage_service).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_connect" => { + let payload_v: Option = take_arg_opt(&mut args, "payload")?; + match crate::commands::gateway::gateway_connect(payload_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_disconnect" => { + match crate::commands::gateway::gateway_disconnect(&state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_status" => { + match crate::commands::gateway::gateway_status(&state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_nudge_connection" => { + let reason_v: Option = take_arg_opt(&mut args, "reason")?; + let force_reconnect_v: Option = take_arg_opt(&mut args, "force_reconnect")?; + match crate::commands::gateway::gateway_nudge_connection(reason_v, force_reconnect_v, &state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_send_chat_ingress_batch" => { + let input_v: GatewayChatIngressBatchInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_send_chat_ingress_batch(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_commit_chat_checkpoint" => { + let input_v: GatewayChatCheckpointInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_commit_chat_checkpoint(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_claim_next" => { + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + let lease_ms_v: Option = take_arg_opt(&mut args, "lease_ms")?; + match crate::commands::gateway::gateway_chat_claim_next(worker_id_v, lease_ms_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_mark_started" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_mark_started(request_id_v, conversation_id_v, worker_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_mark_local_started" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + match crate::commands::gateway::gateway_chat_mark_local_started(request_id_v, conversation_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_mark_local_cancelled" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + match crate::commands::gateway::gateway_chat_mark_local_cancelled(request_id_v, conversation_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_mark_queued_in_gui" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_mark_queued_in_gui(request_id_v, conversation_id_v, worker_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_complete" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_complete(request_id_v, conversation_id_v, worker_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_fail" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: Option = take_arg_opt(&mut args, "conversation_id")?; + let error_code_v: String = take_arg(&mut args, "error_code")?; + let message_v: String = take_arg(&mut args, "message")?; + let terminal_v: bool = take_arg(&mut args, "terminal")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_fail(request_id_v, conversation_id_v, error_code_v, message_v, terminal_v, worker_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_cancel_request" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_cancel_request(request_id_v, conversation_id_v, worker_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_heartbeat" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_heartbeat(request_id_v, worker_id_v, &state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_runtime_heartbeat" => { + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + let state_v: String = take_arg(&mut args, "state")?; + let visible_v: bool = take_arg(&mut args, "visible")?; + let active_run_count_v: u32 = take_arg(&mut args, "active_run_count")?; + match crate::commands::gateway::gateway_chat_runtime_heartbeat(worker_id_v, state_v, visible_v, active_run_count_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_release_lease" => { + let request_id_v: String = take_arg(&mut args, "request_id")?; + let worker_id_v: String = take_arg(&mut args, "worker_id")?; + match crate::commands::gateway::gateway_chat_release_lease(request_id_v, worker_id_v, &state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_chat_queue_respond" => { + let input_v: GatewayChatQueueResponseInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_chat_queue_respond(input_v, &state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_publish_chat_queue_event" => { + let input_v: GatewayChatQueueEventInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_publish_chat_queue_event(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_publish_settings_sync" => { + let payload_v: Value = take_arg(&mut args, "payload")?; + match crate::commands::gateway::gateway_publish_settings_sync(payload_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_tunnel_state" => { + match crate::commands::gateway::gateway_tunnel_state(&state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_tunnel_create" => { + let input_v: GatewayTunnelCreateInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_tunnel_create(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_tunnel_update" => { + let input_v: GatewayTunnelUpdateInput = take_arg(&mut args, "input")?; + match crate::commands::gateway::gateway_tunnel_update(input_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_tunnel_close" => { + let tunnel_id_v: String = take_arg(&mut args, "tunnel_id")?; + match crate::commands::gateway::gateway_tunnel_close(tunnel_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "gateway_tunnel_check" => { + let tunnel_id_v: Option = take_arg_opt(&mut args, "tunnel_id")?; + match crate::commands::gateway::gateway_tunnel_check(tunnel_id_v, &state.ctx.gateway_controller).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "workspace_watch_set" => { + let workdirs_v: Vec = take_arg(&mut args, "workdirs")?; + match crate::commands::gateway::workspace_watch_set(workdirs_v, &state.ctx.gateway_controller) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== mcp ===== + "mcp_list_tools" => { + let servers_v: Vec = take_arg(&mut args, "servers")?; + match crate::commands::mcp::mcp_list_tools(&state.mcp_runtime, servers_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "mcp_call_tool" => { + let server_id_v: String = take_arg(&mut args, "server_id")?; + let tool_name_v: String = take_arg(&mut args, "tool_name")?; + let arguments_v: Value = take_arg(&mut args, "arguments")?; + let run_id_v: Option = take_arg_opt(&mut args, "run_id")?; + match crate::commands::mcp::mcp_call_tool(&state.mcp_runtime, &state.shell_runs, server_id_v, tool_name_v, arguments_v, run_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "mcp_runtime_status" => { + let server_id_v: String = take_arg(&mut args, "server_id")?; + match crate::commands::mcp::mcp_runtime_status(&state.mcp_runtime, server_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "mcp_stop_server" => { + let server_id_v: String = take_arg(&mut args, "server_id")?; + match crate::commands::mcp::mcp_stop_server(&state.mcp_runtime, server_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "mcp_test_server" => { + let server_v: McpServerConfig = take_arg(&mut args, "server")?; + let include_schema_v: Option = take_arg_opt(&mut args, "include_schema")?; + let persist_v: Option = take_arg_opt(&mut args, "persist")?; + match crate::commands::mcp::mcp_test_server(&state.mcp_runtime, server_v, include_schema_v, persist_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "mcp_restart_server" => { + let server_v: McpServerConfig = take_arg(&mut args, "server")?; + let include_schema_v: Option = take_arg_opt(&mut args, "include_schema")?; + let persist_v: Option = take_arg_opt(&mut args, "persist")?; + match crate::commands::mcp::mcp_restart_server(&state.mcp_runtime, server_v, include_schema_v, persist_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== memory ===== + "memory_list" => { + let args_v: MemoryListArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_list(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_read" => { + let args_v: MemoryReadArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_read(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_search" => { + let args_v: MemorySearchArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_search(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_write" => { + let args_v: MemoryWriteArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_write(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_update" => { + let args_v: MemoryUpdateArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_update(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_delete" => { + let args_v: MemoryDeleteArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_delete(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_delete_project" => { + let args_v: MemoryDeleteProjectArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_delete_project(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_accept" => { + let args_v: MemoryAcceptArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_accept(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_apply_batch" => { + let args_v: MemoryBatchArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_apply_batch(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_run_create" => { + let args_v: MemoryOrganizeRunCreateArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_organize_run_create(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_run_update" => { + let args_v: MemoryOrganizeRunUpdateArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_organize_run_update(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_run_list" => { + let args_v: Option = take_arg_opt(&mut args, "args")?; + match crate::commands::memory::memory_organize_run_list(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_run_read" => { + let args_v: MemoryOrganizeRunReadArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_organize_run_read(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_run_clear_history" => { + match crate::commands::memory::memory_organize_run_clear_history(&state.ctx.memory_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_due_claim" => { + let args_v: MemoryOrganizeDueClaimArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_organize_due_claim(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_organize_due_complete" => { + let args_v: MemoryOrganizeRunUpdateArgs = take_arg(&mut args, "args")?; + match crate::commands::memory::memory_organize_due_complete(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_index_overview" => { + let workdir_v: Option = take_arg_opt(&mut args, "workdir")?; + match crate::commands::memory::memory_index_overview(&state.ctx.memory_store, workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_paths_info" => { + match crate::commands::memory::memory_paths_info(&state.ctx.memory_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_recent_rejections" => { + let args_v: Option = take_arg_opt(&mut args, "args")?; + match crate::commands::memory::memory_recent_rejections(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_today_local_date" => { + let rollover_hour_v: Option = take_arg_opt(&mut args, "rollover_hour")?; + match crate::commands::memory::memory_today_local_date(&state.ctx.memory_store, rollover_hour_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_today_daily" => { + let rollover_hour_v: Option = take_arg_opt(&mut args, "rollover_hour")?; + match crate::commands::memory::memory_today_daily(&state.ctx.memory_store, rollover_hour_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_quota_summary" => { + let args_v: Option = take_arg_opt(&mut args, "args")?; + match crate::commands::memory::memory_quota_summary(&state.ctx.memory_store, args_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "memory_wipe_all" => { + match crate::commands::memory::memory_wipe_all(&state.ctx.memory_store).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== process ===== + "managed_process_start" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let command_v: String = take_arg(&mut args, "command")?; + let cwd_v: Option = take_arg_opt(&mut args, "cwd")?; + let label_v: Option = take_arg_opt(&mut args, "label")?; + let isolated_v: Option = take_arg_opt(&mut args, "isolated")?; + match crate::commands::process::managed_process_start(&state.ctx.managed_process_registry, workdir_v, command_v, cwd_v, label_v, isolated_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "managed_process_status" => { + let process_id_v: Option = take_arg_opt(&mut args, "process_id")?; + match crate::commands::process::managed_process_status(&state.ctx.managed_process_registry, process_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "managed_process_stop" => { + let process_id_v: String = take_arg(&mut args, "process_id")?; + match crate::commands::process::managed_process_stop(&state.ctx.managed_process_registry, process_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "managed_process_read_log" => { + let process_id_v: String = take_arg(&mut args, "process_id")?; + let max_bytes_v: Option = take_arg_opt(&mut args, "max_bytes")?; + match crate::commands::process::managed_process_read_log(&state.ctx.managed_process_registry, process_id_v, max_bytes_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "managed_process_snapshot" => { + match crate::commands::process::managed_process_snapshot(&state.ctx.managed_process_registry) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "managed_process_clear" => { + let process_id_v: Option = take_arg_opt(&mut args, "process_id")?; + match crate::commands::process::managed_process_clear(&state.ctx.managed_process_registry, process_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== sftp ===== + "sftp_list" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let side_v: String = take_arg(&mut args, "side")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + match crate::commands::sftp::sftp_list(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, side_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_stat" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let side_v: String = take_arg(&mut args, "side")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + match crate::commands::sftp::sftp_stat(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, side_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_read_text" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let path_v: String = take_arg(&mut args, "path")?; + let offset_v: Option = take_arg_opt(&mut args, "offset")?; + let max_bytes_v: Option = take_arg_opt(&mut args, "max_bytes")?; + match crate::commands::sftp::sftp_read_text(&state.ctx.sftp_registry, session_id_v, project_path_key_v, path_v, offset_v, max_bytes_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_write_text" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let path_v: String = take_arg(&mut args, "path")?; + let content_v: String = take_arg(&mut args, "content")?; + let overwrite_v: Option = take_arg_opt(&mut args, "overwrite")?; + let create_parent_dirs_v: Option = take_arg_opt(&mut args, "create_parent_dirs")?; + match crate::commands::sftp::sftp_write_text(&state.ctx.sftp_registry, session_id_v, project_path_key_v, path_v, content_v, overwrite_v, create_parent_dirs_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_mkdir" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let side_v: String = take_arg(&mut args, "side")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::sftp::sftp_mkdir(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, side_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_rename" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let side_v: String = take_arg(&mut args, "side")?; + let from_path_v: String = take_arg(&mut args, "from_path")?; + let to_path_v: String = take_arg(&mut args, "to_path")?; + match crate::commands::sftp::sftp_rename(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, side_v, from_path_v, to_path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_delete" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let side_v: String = take_arg(&mut args, "side")?; + let path_v: String = take_arg(&mut args, "path")?; + let recursive_v: Option = take_arg_opt(&mut args, "recursive")?; + match crate::commands::sftp::sftp_delete(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, side_v, path_v, recursive_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_transfer" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let direction_v: String = take_arg(&mut args, "direction")?; + let source_path_v: String = take_arg(&mut args, "source_path")?; + let target_path_v: String = take_arg(&mut args, "target_path")?; + let recursive_v: Option = take_arg_opt(&mut args, "recursive")?; + let overwrite_v: Option = take_arg_opt(&mut args, "overwrite")?; + match crate::commands::sftp::sftp_transfer(&state.ctx.sftp_registry, session_id_v, project_path_key_v, workdir_v, direction_v, source_path_v, target_path_v, recursive_v, overwrite_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_cancel_transfer" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let transfer_id_v: String = take_arg(&mut args, "transfer_id")?; + match crate::commands::sftp::sftp_cancel_transfer(&state.ctx.sftp_registry, session_id_v, transfer_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "sftp_transfer_status" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let transfer_id_v: String = take_arg(&mut args, "transfer_id")?; + match crate::commands::sftp::sftp_transfer_status(&state.ctx.sftp_registry, session_id_v, transfer_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== terminal ===== + "terminal_shell_options" => { + to_value(crate::commands::terminal::terminal_shell_options()) + }, + "terminal_list" => { + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + to_value(crate::commands::terminal::terminal_list(&state.ctx.terminal_registry, project_path_key_v)) + }, + "terminal_create" => { + let cwd_v: String = take_arg(&mut args, "cwd")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let shell_v: Option = take_arg_opt(&mut args, "shell")?; + let title_v: Option = take_arg_opt(&mut args, "title")?; + let cols_v: Option = take_arg_opt(&mut args, "cols")?; + let rows_v: Option = take_arg_opt(&mut args, "rows")?; + match crate::commands::terminal::terminal_create(&state.ctx.terminal_registry, cwd_v, project_path_key_v, shell_v, title_v, cols_v, rows_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_create_ssh" => { + let cwd_v: String = take_arg(&mut args, "cwd")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let ssh_host_id_v: String = take_arg(&mut args, "ssh_host_id")?; + let title_v: Option = take_arg_opt(&mut args, "title")?; + let cols_v: Option = take_arg_opt(&mut args, "cols")?; + let rows_v: Option = take_arg_opt(&mut args, "rows")?; + let sftp_enabled_v: Option = take_arg_opt(&mut args, "sftp_enabled")?; + match crate::commands::terminal::terminal_create_ssh(&state.ctx.terminal_registry, cwd_v, project_path_key_v, ssh_host_id_v, title_v, cols_v, rows_v, sftp_enabled_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_answer_ssh_prompt" => { + let prompt_id_v: String = take_arg(&mut args, "prompt_id")?; + let prompt_answer_v: Option = take_arg_opt(&mut args, "prompt_answer")?; + let trust_host_key_v: Option = take_arg_opt(&mut args, "trust_host_key")?; + match crate::commands::terminal::terminal_answer_ssh_prompt(&state.ctx.terminal_registry, prompt_id_v, prompt_answer_v, trust_host_key_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_cancel_ssh_prompt" => { + let prompt_id_v: String = take_arg(&mut args, "prompt_id")?; + match crate::commands::terminal::terminal_cancel_ssh_prompt(&state.ctx.terminal_registry, prompt_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_reconnect" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + match crate::commands::terminal::terminal_ssh_reconnect(&state.ctx.terminal_registry, session_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_latency" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + match crate::commands::terminal::terminal_ssh_latency(&state.ctx.terminal_registry, session_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_exec" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let command_v: String = take_arg(&mut args, "command")?; + let cwd_v: Option = take_arg_opt(&mut args, "cwd")?; + let timeout_ms_v: Option = take_arg_opt(&mut args, "timeout_ms")?; + let max_bytes_v: Option = take_arg_opt(&mut args, "max_bytes")?; + let run_id_v: Option = take_arg_opt(&mut args, "run_id")?; + match crate::commands::terminal::terminal_ssh_exec(&state.ctx.terminal_registry, &state.shell_runs, session_id_v, command_v, cwd_v, timeout_ms_v, max_bytes_v, run_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_local_forward_start" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + let remote_host_v: String = take_arg(&mut args, "remote_host")?; + let remote_port_v: u32 = take_arg(&mut args, "remote_port")?; + let local_port_v: Option = take_arg_opt(&mut args, "local_port")?; + match crate::commands::terminal::terminal_ssh_local_forward_start(&state.ctx.terminal_registry, session_id_v, project_path_key_v, remote_host_v, remote_port_v, local_port_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_local_forward_list" => { + let session_id_v: Option = take_arg_opt(&mut args, "session_id")?; + let project_path_key_v: Option = take_arg_opt(&mut args, "project_path_key")?; + match crate::commands::terminal::terminal_ssh_local_forward_list(&state.ctx.terminal_registry, session_id_v, project_path_key_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_local_forward_stop" => { + let forward_id_v: String = take_arg(&mut args, "forward_id")?; + let session_id_v: Option = take_arg_opt(&mut args, "session_id")?; + match crate::commands::terminal::terminal_ssh_local_forward_stop(&state.ctx.terminal_registry, forward_id_v, session_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_ssh_local_forward_check_port" => { + let local_port_v: u32 = take_arg(&mut args, "local_port")?; + match crate::commands::terminal::terminal_ssh_local_forward_check_port(local_port_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "ssh_terminal_tabs_list" => { + let project_path_key_v: String = take_arg(&mut args, "project_path_key")?; + match crate::commands::terminal::ssh_terminal_tabs_list(&state.ctx.terminal_registry, project_path_key_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "ssh_terminal_tab_open" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let kind_v: String = take_arg(&mut args, "kind")?; + match crate::commands::terminal::ssh_terminal_tab_open(&state.ctx.terminal_registry, session_id_v, kind_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "ssh_terminal_tab_close" => { + let tab_id_v: String = take_arg(&mut args, "tab_id")?; + match crate::commands::terminal::ssh_terminal_tab_close(&state.ctx.terminal_registry, tab_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_stream_attach" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let max_bytes_v: Option = take_arg_opt(&mut args, "max_bytes")?; + match crate::commands::terminal::terminal_stream_attach(&state.ctx.terminal_registry, session_id_v, max_bytes_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_stream_input" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let bytes_v: Vec = take_arg(&mut args, "bytes")?; + match crate::commands::terminal::terminal_stream_input(&state.ctx.terminal_registry, session_id_v, bytes_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_stream_resize" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let cols_v: u16 = take_arg(&mut args, "cols")?; + let rows_v: u16 = take_arg(&mut args, "rows")?; + match crate::commands::terminal::terminal_stream_resize(&state.ctx.terminal_registry, session_id_v, cols_v, rows_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_rename" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + let title_v: String = take_arg(&mut args, "title")?; + match crate::commands::terminal::terminal_rename(&state.ctx.terminal_registry, session_id_v, title_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_close" => { + let session_id_v: String = take_arg(&mut args, "session_id")?; + match crate::commands::terminal::terminal_close(&state.ctx.terminal_registry, &state.ctx.sftp_registry, session_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_close_project" => { + let project_path_key_v: String = take_arg(&mut args, "project_path_key")?; + match crate::commands::terminal::terminal_close_project(&state.ctx.terminal_registry, &state.ctx.sftp_registry, project_path_key_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "terminal_read_tail" => { + let project_path_key_v: String = take_arg(&mut args, "project_path_key")?; + let session_id_v: Option = take_arg_opt(&mut args, "session_id")?; + let max_bytes_v: Option = take_arg_opt(&mut args, "max_bytes")?; + match crate::commands::terminal::terminal_read_tail(&state.ctx.terminal_registry, project_path_key_v, session_id_v, max_bytes_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== shell ===== + "shell_run" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let command_v: String = take_arg(&mut args, "command")?; + let cwd_v: Option = take_arg_opt(&mut args, "cwd")?; + let timeout_ms_v: Option = take_arg_opt(&mut args, "timeout_ms")?; + let max_timeout_ms_v: Option = take_arg_opt(&mut args, "max_timeout_ms")?; + let provider_id_v: Option = take_arg_opt(&mut args, "provider_id")?; + let run_id_v: Option = take_arg_opt(&mut args, "run_id")?; + match crate::commands::shell::shell_run(&state.shell_runs, workdir_v, command_v, cwd_v, timeout_ms_v, max_timeout_ms_v, provider_id_v, run_id_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "runtime_cancel" => { + let run_id_v: String = take_arg(&mut args, "run_id")?; + to_value(crate::commands::shell::runtime_cancel(&state.shell_runs, run_id_v)) + }, + // ===== chat_file_links ===== + "open_chat_file_link" => { + let conversation_id_v: String = take_arg(&mut args, "conversation_id")?; + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let source_v: String = take_arg(&mut args, "source")?; + let line_v: Option = take_arg_opt(&mut args, "line")?; + let end_line_v: Option = take_arg_opt(&mut args, "end_line")?; + let column_v: Option = take_arg_opt(&mut args, "column")?; + let open_in_file_manager_v: Option = take_arg_opt(&mut args, "open_in_file_manager")?; + match crate::commands::chat_file_links::open_chat_file_link(conversation_id_v, workdir_v, path_v, source_v, line_v, end_line_v, column_v, open_in_file_manager_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + // ===== fs ===== + "fs_read_image_source" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let source_v: String = take_arg(&mut args, "source")?; + let source_type_v: Option = take_arg_opt(&mut args, "source_type")?; + let mime_type_v: Option = take_arg_opt(&mut args, "mime_type")?; + match crate::commands::fs::fs_read_image_source(workdir_v, source_v, source_type_v, mime_type_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_read_workspace_image" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::fs::fs_read_workspace_image(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_read_text" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let start_line_v: Option = take_arg_opt(&mut args, "start_line")?; + let limit_v: Option = take_arg_opt(&mut args, "limit")?; + let page_start_v: Option = take_arg_opt(&mut args, "page_start")?; + let page_limit_v: Option = take_arg_opt(&mut args, "page_limit")?; + let cell_start_v: Option = take_arg_opt(&mut args, "cell_start")?; + let cell_limit_v: Option = take_arg_opt(&mut args, "cell_limit")?; + match crate::commands::fs::fs_read_text(workdir_v, path_v, start_line_v, limit_v, page_start_v, page_limit_v, cell_start_v, cell_limit_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_read_editable_text" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::fs::fs_read_editable_text(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_path_status" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::fs::fs_path_status(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_write_text" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let content_v: String = take_arg(&mut args, "content")?; + let mode_v: String = take_arg(&mut args, "mode")?; + let expected_mtime_ms_v: Option = take_arg_opt(&mut args, "expected_mtime_ms")?; + let expected_content_hash_v: Option = take_arg_opt(&mut args, "expected_content_hash")?; + match crate::commands::fs::fs_write_text(workdir_v, path_v, content_v, mode_v, expected_mtime_ms_v, expected_content_hash_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_edit_text" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let old_string_v: String = take_arg(&mut args, "old_string")?; + let new_string_v: String = take_arg(&mut args, "new_string")?; + let expected_replacements_v: Option = take_arg_opt(&mut args, "expected_replacements")?; + let replace_all_v: Option = take_arg_opt(&mut args, "replace_all")?; + let expected_mtime_ms_v: Option = take_arg_opt(&mut args, "expected_mtime_ms")?; + let expected_content_hash_v: Option = take_arg_opt(&mut args, "expected_content_hash")?; + match crate::commands::fs::fs_edit_text(workdir_v, path_v, old_string_v, new_string_v, expected_replacements_v, replace_all_v, expected_mtime_ms_v, expected_content_hash_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_delete" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::fs::fs_delete(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_open_workspace_path" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let mode_v: Option = take_arg_opt(&mut args, "mode")?; + match crate::commands::fs::fs_open_workspace_path(workdir_v, path_v, mode_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_create_dir" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::fs::fs_create_dir(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_rename" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let from_path_v: String = take_arg(&mut args, "from_path")?; + let to_path_v: String = take_arg(&mut args, "to_path")?; + match crate::commands::fs::fs_rename(workdir_v, from_path_v, to_path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_roots" => { + match crate::commands::fs::fs_roots().await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "fs_list_dirs" => { + let path_v: String = take_arg(&mut args, "path")?; + let max_results_v: Option = take_arg_opt(&mut args, "max_results")?; + match crate::commands::fs::fs_list_dirs(path_v, max_results_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "fs_list" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + let depth_v: Option = take_arg_opt(&mut args, "depth")?; + let offset_v: Option = take_arg_opt(&mut args, "offset")?; + let max_results_v: Option = take_arg_opt(&mut args, "max_results")?; + let show_hidden_v: Option = take_arg_opt(&mut args, "show_hidden")?; + match crate::commands::fs::fs_list(workdir_v, path_v, depth_v, offset_v, max_results_v, show_hidden_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_glob" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + let pattern_v: String = take_arg(&mut args, "pattern")?; + let offset_v: Option = take_arg_opt(&mut args, "offset")?; + let max_results_v: Option = take_arg_opt(&mut args, "max_results")?; + let sort_by_v: Option = take_arg_opt(&mut args, "sort_by")?; + match crate::commands::fs::fs_glob(workdir_v, path_v, pattern_v, offset_v, max_results_v, sort_by_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_grep" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + let pattern_v: String = take_arg(&mut args, "pattern")?; + let file_pattern_v: Option = take_arg_opt(&mut args, "file_pattern")?; + let ignore_case_v: Option = take_arg_opt(&mut args, "ignore_case")?; + let output_mode_v: Option = take_arg_opt(&mut args, "output_mode")?; + let head_limit_v: Option = take_arg_opt(&mut args, "head_limit")?; + let offset_v: Option = take_arg_opt(&mut args, "offset")?; + let context_v: Option = take_arg_opt(&mut args, "context")?; + let multiline_v: Option = take_arg_opt(&mut args, "multiline")?; + match crate::commands::fs::fs_grep(workdir_v, path_v, pattern_v, file_pattern_v, ignore_case_v, output_mode_v, head_limit_v, offset_v, context_v, multiline_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(format!("{e:?}"))), + } + }, + "fs_mention_list" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let max_results_v: Option = take_arg_opt(&mut args, "max_results")?; + let query_v: Option = take_arg_opt(&mut args, "query")?; + let show_hidden_v: Option = take_arg_opt(&mut args, "show_hidden")?; + match crate::commands::fs::fs_mention_list(workdir_v, max_results_v, query_v, show_hidden_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== git ===== + "git_status" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_status(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_discover_repositories" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_discover_repositories(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_branches" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_branches(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_switch_branch" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let branch_v: String = take_arg(&mut args, "branch")?; + let kind_v: Option = take_arg_opt(&mut args, "kind")?; + match crate::commands::git::git_switch_branch(workdir_v, branch_v, kind_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_create_branch" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let branch_v: String = take_arg(&mut args, "branch")?; + let start_point_v: Option = take_arg_opt(&mut args, "start_point")?; + match crate::commands::git::git_create_branch(workdir_v, branch_v, start_point_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_init" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let branch_v: Option = take_arg_opt(&mut args, "branch")?; + let user_name_v: Option = take_arg_opt(&mut args, "user_name")?; + let user_email_v: Option = take_arg_opt(&mut args, "user_email")?; + match crate::commands::git::git_init(workdir_v, branch_v, user_name_v, user_email_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_clone_repository" => { + let parent_v: String = take_arg(&mut args, "parent")?; + let name_v: String = take_arg(&mut args, "name")?; + let remote_url_v: String = take_arg(&mut args, "remote_url")?; + let branch_v: Option = take_arg_opt(&mut args, "branch")?; + match crate::commands::git::git_clone_repository(parent_v, name_v, remote_url_v, branch_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_clone_repository_start" => { + let parent_v: String = take_arg(&mut args, "parent")?; + let name_v: String = take_arg(&mut args, "name")?; + let remote_url_v: String = take_arg(&mut args, "remote_url")?; + let branch_v: Option = take_arg_opt(&mut args, "branch")?; + match crate::commands::git::git_clone_repository_start(&state.ctx.git_clone_task_registry, parent_v, name_v, remote_url_v, branch_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_clone_repository_tasks" => { + match crate::commands::git::git_clone_repository_tasks(&state.ctx.git_clone_task_registry) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_clone_repository_cancel" => { + let task_id_v: String = take_arg(&mut args, "task_id")?; + match crate::commands::git::git_clone_repository_cancel(&state.ctx.git_clone_task_registry, task_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_clone_repository_dismiss" => { + let task_id_v: String = take_arg(&mut args, "task_id")?; + match crate::commands::git::git_clone_repository_dismiss(&state.ctx.git_clone_task_registry, task_id_v) { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_list_remote_branches" => { + let remote_url_v: String = take_arg(&mut args, "remote_url")?; + match crate::commands::git::git_list_remote_branches(remote_url_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_diff" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let mode_v: Option = take_arg_opt(&mut args, "mode")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + match crate::commands::git::git_diff(workdir_v, mode_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_log" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let limit_v: Option = take_arg_opt(&mut args, "limit")?; + let skip_v: Option = take_arg_opt(&mut args, "skip")?; + match crate::commands::git::git_log(workdir_v, limit_v, skip_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_commit_details" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let commit_v: String = take_arg(&mut args, "commit")?; + match crate::commands::git::git_commit_details(workdir_v, commit_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_compare_commit_with_remote" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let commit_v: String = take_arg(&mut args, "commit")?; + match crate::commands::git::git_compare_commit_with_remote(workdir_v, commit_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_commit_diff" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let commit_v: String = take_arg(&mut args, "commit")?; + let path_v: Option = take_arg_opt(&mut args, "path")?; + match crate::commands::git::git_commit_diff(workdir_v, commit_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_stage" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::git::git_stage(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_stage_all" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_stage_all(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_unstage" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::git::git_unstage(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_unstage_all" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_unstage_all(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_discard" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + let old_path_v: Option = take_arg_opt(&mut args, "old_path")?; + match crate::commands::git::git_discard(workdir_v, path_v, old_path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_discard_all" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_discard_all(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_add_to_gitignore" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::git::git_add_to_gitignore(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_open_system_file_location" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let path_v: String = take_arg(&mut args, "path")?; + match crate::commands::git::git_open_system_file_location(workdir_v, path_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_commit" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let message_v: String = take_arg(&mut args, "message")?; + match crate::commands::git::git_commit(workdir_v, message_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_fetch" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_fetch(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_pull" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_pull(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_set_remote" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let remote_url_v: String = take_arg(&mut args, "remote_url")?; + match crate::commands::git::git_set_remote(workdir_v, remote_url_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_push" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_push(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_delete_branch" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let branch_v: String = take_arg(&mut args, "branch")?; + let force_v: Option = take_arg_opt(&mut args, "force")?; + match crate::commands::git::git_delete_branch(workdir_v, branch_v, force_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_rename_branch" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let branch_v: String = take_arg(&mut args, "branch")?; + let new_branch_v: String = take_arg(&mut args, "new_branch")?; + match crate::commands::git::git_rename_branch(workdir_v, branch_v, new_branch_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_stash_push" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + let message_v: Option = take_arg_opt(&mut args, "message")?; + match crate::commands::git::git_stash_push(workdir_v, message_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "git_stash_pop" => { + let workdir_v: String = take_arg(&mut args, "workdir")?; + match crate::commands::git::git_stash_pop(workdir_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== subagent_worktree ===== + "subagent_worktree_create" => { + let input_v: SubagentWorktreeCreateInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_worktree::subagent_worktree_create(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_worktree_status" => { + let input_v: SubagentWorktreeStatusInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_worktree::subagent_worktree_status(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_worktree_apply" => { + let input_v: SubagentWorktreeApplyInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_worktree::subagent_worktree_apply(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + "subagent_worktree_cleanup" => { + let input_v: SubagentWorktreeCleanupInput = take_arg(&mut args, "input")?; + match crate::commands::subagent_worktree::subagent_worktree_cleanup(input_v).await { + Ok(v) => to_value(v), + Err(e) => Err(HeadlessError::Business(e)), + } + }, + // ===== proxy ===== + "proxy_get_server_info" => { + // BFF 模式:反代路由挂在主 HTTP 服务上(/proxy/*、/image-proxy), + // 前端直接把请求发到主服务端口,由服务端转发上游。token 沿用本地反代 + // 的随机 token,/proxy handler 按同一 token 校验。 + let info = crate::services::proxy::proxy_get_server_info(&state.proxy_server); + to_value(serde_json::json!({ + "baseUrl": state.proxy_base_url, + "token": info.token, + })) + }, + _ => { + eprintln!("[dispatch] unknown command: {cmd}"); + Err(HeadlessError::Business(format!("unknown command: {cmd}"))) + } + } +} + +// ---- Authentication middleware ---- + +/// Bearer token configuration loaded from environment. +#[derive(Clone)] +pub struct AuthConfig { + /// Expected Bearer token; `None` = auth disabled. + pub api_token: Option, +} + +impl AuthConfig { + pub fn from_env() -> Self { + let api_token = std::env::var("LIVEAGENT_API_TOKEN") + .ok().filter(|t| !t.is_empty()); + Self { api_token } + } +} + +/// True when the request is same-origin (browser page served by this server). +/// A request whose Origin scheme+host matches its Host header was issued by +/// the WebUI we serve, i.e. the caller already had access to this port. +fn is_same_origin(req: &axum::http::Request) -> bool { + let Some(origin) = req.headers().get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { + return false; + }; + let Some(host) = req.headers().get(header::HOST).and_then(|v| v.to_str().ok()) else { + return false; + }; + origin == format!("http://{host}") || origin == format!("https://{host}") +} + +async fn auth_middleware( + State(config): State, + req: axum::http::Request, + next: Next, +) -> Result { + // Only the command execution and file-import endpoints are protected by + // the API token. Everything else (static assets, /health, /api/status, + // /proxy/*, /image-proxy) is public by design; WebSocket auth is handled + // separately in ws_handler (same-origin is allowed, otherwise ?token= + // required). + let protected = matches!(req.uri().path(), "/api/invoke" | "/api/files/import"); + if !protected { + return Ok(next.run(req).await); + } + match &config.api_token { + None => Ok(next.run(req).await), + Some(expected) => { + // Same-origin browser requests are already authorized (the caller + // loaded the WebUI from this service). Cross-origin / non-browser + // callers must present the Bearer token. + if is_same_origin(&req) { + return Ok(next.run(req).await); + } + let ok = req.headers().get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map_or(false, |t| t == expected.as_str()); + if ok { Ok(next.run(req).await) } else { Err(StatusCode::UNAUTHORIZED) } + } + } +} + +// ---- CORS / same-origin guard ---- + +/// Allowed extra origins for cross-origin browser access (comma separated). +/// Defaults to same-origin only. Loaded once at startup from +/// `LIVEAGENT_HEADLESS_CORS_ORIGINS`. +#[derive(Clone)] +pub struct CorsConfig { + pub allowed: Vec, +} + +impl CorsConfig { + pub fn from_env() -> Self { + let allowed = std::env::var("LIVEAGENT_HEADLESS_CORS_ORIGINS") + .map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()) + .unwrap_or_default(); + Self { allowed } + } + fn origin_allowed(&self, req: &axum::http::Request) -> Option { + let origin = req.headers().get(header::ORIGIN)?.to_str().ok()?.to_string(); + if is_same_origin(req) || self.allowed.contains(&origin) { + Some(origin) + } else { + None + } + } +} + +/// Guards against cross-site request forgery / cross-origin data theft. +/// +/// - Requests with an `Origin` header that is neither same-origin nor on the +/// allow-list are rejected with 403 before reaching the router. +/// - CORS preflight (OPTIONS) for allowed origins returns a proper 204 with +/// the needed allow headers. +/// - Requests without an `Origin` (curl, server-side callers) pass through for +/// token-based auth to decide. +/// - For the WebSocket upgrade path, an `WsOriginCheck` extension is set so +/// ws_handler can distinguish browser (same-origin, already authorized) +/// connections from non-browser clients (which must present ?token=). +async fn cors_origin_middleware( + State(config): State, + mut req: axum::http::Request, + next: Next, +) -> Result { + let has_origin = req.headers().contains_key(header::ORIGIN); + let is_ws = req.uri().path() == "/ws"; + + let allowed_origin = if has_origin { config.origin_allowed(&req) } else { None }; + + // Cross-origin request that failed the allow-list -> reject before routing. + if has_origin && allowed_origin.is_none() { + return Err(StatusCode::FORBIDDEN); + } + + // Let ws_handler know whether this is an authorized same-origin browser + // WebSocket (Origin present + allowed) or a non-browser client. + if is_ws { + let browser_authorized = allowed_origin.is_some(); + req.extensions_mut().insert(WsOriginCheck { browser_authorized }); + } + + // Preflight: answer with the CORS allow headers directly. + if req.method() == Method::OPTIONS { + let mut builder = Response::builder().status(StatusCode::NO_CONTENT); + if let Some(origin) = &allowed_origin { + builder = builder + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin) + .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, OPTIONS") + .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, Authorization") + .header(header::VARY, "Origin"); + } + return builder.body(axum::body::Body::empty()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR); + } + + let mut resp = next.run(req).await; + if let Some(origin) = &allowed_origin { + let headers = resp.headers_mut(); + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, + origin.parse().unwrap_or_else(|_| header::HeaderValue::from_static("*"))); + let vary = headers.get(header::VARY) + .and_then(|v| v.to_str().ok()) + .map(|v| if v.split(',').any(|p| p.trim() == "Origin") { v.to_string() } + else { format!("{v}, Origin") }) + .unwrap_or_else(|| "Origin".to_string()); + headers.insert(header::VARY, vary.parse().unwrap_or_else(|_| header::HeaderValue::from_static("Origin"))); + } + Ok(resp) +} + +/// Set by [`cors_origin_middleware`] for `/ws` upgrade requests. +#[derive(Clone)] +pub struct WsOriginCheck { + /// true = request came from a same-origin/allow-listed browser page. + pub browser_authorized: bool, +} + +// ---- Rate limiting ---- + +use std::sync::Mutex; + +/// Simple in-memory per-IP rate limiter (token bucket). +#[derive(Clone)] +pub struct RateLimiter { + inner: Arc>>, + max_tokens: u32, + refill_interval: std::time::Duration, +} + +impl RateLimiter { + pub fn new(max_tokens: u32, refill_interval: std::time::Duration) -> Self { + Self { inner: Arc::new(Mutex::new(HashMap::new())), max_tokens, refill_interval } + } + /// Returns `true` if the request is allowed. + pub fn allow(&self, key: &str) -> bool { + let mut map = self.inner.lock().unwrap(); + let now = Instant::now(); + let entry = map.entry(key.to_string()).or_insert((self.max_tokens, now)); + let elapsed = now.duration_since(entry.1).as_secs_f64(); + let refill = (elapsed / self.refill_interval.as_secs_f64() * self.max_tokens as f64) as u32; + if refill > 0 { + entry.0 = (entry.0 + refill).min(self.max_tokens); + entry.1 = now; + } + if entry.0 > 0 { entry.0 -= 1; true } else { false } + } +} + +async fn rate_limit_middleware( + State(limiter): State, + req: axum::http::Request, + next: Next, +) -> Result { + // Only rate-limit /api/invoke + if req.uri().path() != "/api/invoke" { + return Ok(next.run(req).await); + } + // Extract the client IP. X-Forwarded-For is only trusted when explicitly + // enabled (LIVEAGENT_TRUST_PROXY_HEADERS=1) — otherwise it is spoofable + // and would let callers bypass the rate limit by cycling fake IPs. + let ip = if std::env::var("LIVEAGENT_TRUST_PROXY_HEADERS").is_ok() { + req.headers().get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + } else { + String::new() + }; + let ip = if ip.is_empty() { + req.extensions().get::>() + .map(|ci| ci.0.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + } else { + ip + }; + // Loopback (local web UI) is trusted tooling: exempt from rate limiting. + // Without this, the browser's parallel frontend requests quickly exhaust + // the token bucket and the UI shows HTTP 429 for every invoke. + let loopback = ip == "127.0.0.1" + || ip == "::1" + || ip.starts_with("::1%") + || ip == "localhost"; + if loopback { + return Ok(next.run(req).await); + } + if limiter.allow(&ip) { + Ok(next.run(req).await) + } else { + eprintln!("[rate-limit] rejected {ip}"); + Err(StatusCode::TOO_MANY_REQUESTS) + } +} + +// ---- WebSocket broadcast with backpressure ---- + +/// Maximum pending messages per WebSocket client before oldest are dropped. +const WS_SEND_QUEUE_LIMIT: usize = 256; +/// Log every N dropped events to avoid log flooding. +const WS_LAGGED_LOG_INTERVAL: u64 = 100; + +async fn handle_ws(mut socket: WebSocket, state: HeadlessState) { + let mut rx = state.emitter.subscribe(); + let mut lagged_total: u64 = 0; + let mut pending: Vec = Vec::new(); + + loop { + // Phase 1: receive new events and enqueue + while let Ok(ev) = rx.try_recv() { + if let Ok(text) = serde_json::to_string(&ev) { + if pending.len() >= WS_SEND_QUEUE_LIMIT { + pending.remove(0); + lagged_total += 1; + if lagged_total % WS_LAGGED_LOG_INTERVAL == 0 { + eprintln!("[ws] backpressure: {lagged_total} events dropped"); + } + } + pending.push(text); + } + } + + // Phase 2: flush pending to socket + while let Some(text) = pending.first() { + match tokio::time::timeout( + std::time::Duration::from_millis(50), + socket.send(Message::Text(text.as_str().into())), + ).await { + Ok(Ok(_)) => { pending.remove(0); } + _ => { + // Send failed or timed out — client is slow + eprintln!("[ws] send timeout/failure, dropping {} pending", pending.len()); + pending.clear(); + if lagged_total > 0 { + eprintln!("[ws] client disconnected after {lagged_total} total drops"); + } + return; + } + } + } + + // Phase 3: wait for next event or yield + match tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()).await { + Ok(Ok(ev)) => { + if let Ok(text) = serde_json::to_string(&ev) { pending.push(text); } + } + Ok(Err(broadcast::error::RecvError::Lagged(n))) => { + lagged_total += n as u64; + } + Ok(Err(broadcast::error::RecvError::Closed)) => return, + _ => {} // timeout — loop back to receive more + } + } +} + +// ---- HTTP handlers ---- + +async fn health(AxumState(_state): AxumState) -> Json { + Json(serde_json::json!({ + "ok": true, + "version": crate::app_version(), + "mode": "headless", + })) +} + +async fn api_status(AxumState(state): AxumState) -> Json { + match crate::commands::gateway::gateway_status(&state.ctx.gateway_controller) { + Ok(snapshot) => Json(serde_json::json!({ "ok": true, "gateway": snapshot })), + Err(error) => Json(serde_json::json!({ "ok": false, "error": error })), + } +} + +#[derive(Deserialize)] +struct InvokeRequest { + cmd: String, + args: Option, +} + +async fn invoke_handler( + AxumState(state): AxumState, + Json(req): Json, +) -> Json { + let t0 = Instant::now(); + let args = req.args.unwrap_or(Value::Null); + let result = dispatch(&state, &req.cmd, args).await; + let elapsed_ms = t0.elapsed().as_millis(); + match result { + Ok(value) => { + if elapsed_ms > 1000 { + eprintln!("[invoke] {} ok in {elapsed_ms}ms", req.cmd); + } + Json(serde_json::json!({ "ok": true, "value": value })) + } + Err(error) => { + let error_code = match &error { + HeadlessError::DesktopOnly(_) => "DESKTOP_ONLY", + HeadlessError::Unavailable(_) => "UNAVAILABLE", + HeadlessError::Business(_) => "BUSINESS_ERROR", + }; + eprintln!("[invoke] {} err ({error_code}): {error}", req.cmd); + Json(serde_json::json!({ + "ok": false, + "error": error.to_string(), + "code": error_code, + })) + } + } +} + +/// POST /api/files/import — multipart file upload, mirroring the +/// agent-gateway WebUI protocol +/// (crates/agent-gateway/web/src/lib/uploadReadableFiles.ts). Multipart +/// fields: `workdir` (string) + one or more `files` (file parts). The +/// `agent_id` query param is accepted for protocol parity and ignored +/// (headless mode is single-agent). +/// +/// Returns the same shape as the Tauri command surface: +/// { "files": [{ relativePath, absolutePath, fileName, kind, sizeBytes }], +/// "skipped": [...] } +/// This replaces the base64-in-JSON path for /api/invoke uploads: no 33% +/// base64 expansion, no JSON double-buffering — multipart parts are read +/// straight into memory per file (same as the Go gateway's io.ReadAll). +async fn import_files_handler( + AxumState(_state): AxumState, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut workdir: Option = None; + let mut uploads = Vec::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| (StatusCode::BAD_REQUEST, format!("multipart parse failed: {err}")))? + { + let name = field.name(); + // Match on the Option directly (not `"..." =>`) so the verify_headless.py + // dispatch-coverage scanner (which counts line-start `"x" =>` arms) does + // not mistake multipart field names for /api/invoke dispatch arms. + match name { + Some("workdir") => { + if workdir.is_none() { + let text = field + .text() + .await + .map_err(|err| (StatusCode::BAD_REQUEST, format!("read workdir failed: {err}")))?; + workdir = Some(text.trim().to_string()); + } + } + Some("files") => { + let file_name = field.file_name().unwrap_or("").trim().to_string(); + let mime_type = field.content_type().map(|s| s.to_string()); + let content = field + .bytes() + .await + .map_err(|err| (StatusCode::BAD_REQUEST, format!("read file part failed: {err}")))? + .to_vec(); + uploads.push(crate::commands::system::SystemReadableFileUploadInput { + file_name, + mime_type, + content, + }); + } + // Unknown fields are ignored for forward compatibility (the Go + // gateway only reads workdir/files via FormValue as well). + _ => {} + } + } + + let workdir = + workdir.ok_or_else(|| (StatusCode::BAD_REQUEST, "workdir is required".to_string()))?; + if uploads.is_empty() { + return Err((StatusCode::BAD_REQUEST, "files is required".to_string())); + } + + // Reuse the exact same import pipeline as the Tauri commands (kind + // detection, UTF-8 transcode, staging write, entry building). + match crate::commands::system::system_import_uploaded_readable_files_sync(workdir, uploads) { + Ok(response) => { + let value = serde_json::to_value(response).map_err(|err| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("serialize response: {err}")) + })?; + Ok(Json(value)) + } + Err(message) => Err((StatusCode::BAD_REQUEST, message)), + } +} + +async fn ws_handler( + ws: WebSocketUpgrade, + AxumState(state): AxumState, + Extension(origin_check): Extension, + Query(params): Query>, +) -> impl IntoResponse { + // Browser connections from the same origin / allow-list are already + // authorized (the page is served by this server). Non-browser clients + // (no Origin, e.g. curl) must present ?token= when an API token is + // configured — this stops cross-origin / scripted subscriptions to the + // event stream (session content exfiltration). + if !origin_check.browser_authorized { + if let Some(expected) = &state.api_token { + let ok = params.get("token").map_or(false, |t| t == expected.as_str()); + if !ok { + eprintln!("[ws] rejected connection: missing/invalid token"); + return (StatusCode::UNAUTHORIZED, "ws: missing or invalid token").into_response(); + } + } + } + ws.on_upgrade(move |socket| handle_ws(socket, state)) +} + +// ---- Static file serving (compile-time or runtime) ---- + +#[cfg(not(feature = "runtime-fallback"))] +mod embedded { + include!(concat!(env!("OUT_DIR"), "/embedded_web.rs")); +} + +/// Serve embedded or runtime static files with SPA fallback. +async fn serve_static( + AxumPath(path): AxumPath, +) -> impl IntoResponse { + serve_static_path(&path).await +} + +/// Root path handler (no path capture needed). +async fn serve_root() -> impl IntoResponse { + serve_static_path("").await +} + +async fn serve_static_path(path: &str) -> impl IntoResponse { + #[cfg(not(feature = "runtime-fallback"))] + { + let file_path = if path.is_empty() || path == "/" { "index.html".to_string() } + else { path.trim_start_matches('/').to_string() }; + match embedded::EMBEDDED_FILES.get(file_path.as_str()) { + Some(content) => { + let ct = embedded::mime_for_path(&file_path); + ([(header::CONTENT_TYPE, ct.to_string())], *content).into_response() + } + None => { + // SPA fallback + match embedded::EMBEDDED_FILES.get("index.html") { + Some(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], *html).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } + } + } + } + #[cfg(feature = "runtime-fallback")] + { + let root = match web_root() { + Some(root) => root, + None => return StatusCode::NOT_FOUND.into_response(), + }; + let file = tokio::fs::read(root.join(&path)).await; + match file { + Ok(bytes) => { + let ct = runtime_mime_for_path(&path); + ([(header::CONTENT_TYPE, ct.to_string())], bytes).into_response() + } + Err(_) => { + // SPA fallback + match tokio::fs::read(root.join("index.html")).await { + Ok(bytes) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], bytes).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } + } + } + } +} + +// ---- Router ---- + +pub fn build_router(state: HeadlessState) -> Router { + let auth = AuthConfig::from_env(); + // Same-origin CORS guard (+ optional allow-list from env). + let cors_config = CorsConfig::from_env(); + // Default: 60 requests per minute for /api/invoke + let limiter = RateLimiter::new(60, std::time::Duration::from_secs(60)); + // File uploads now arrive as multipart parts on /api/files/import (the + // same protocol as the agent-gateway WebUI), but /api/invoke bodies may + // still carry paste/attachment payloads. axum's default body limit is + // 2MB, so allow a configurable cap (default 128MB, override via + // LIVEAGENT_HEADLESS_MAX_BODY_MB). Multipart import parts are not + // base64-expanded (~33% smaller than the old JSON path). + let max_body_bytes = std::env::var("LIVEAGENT_HEADLESS_MAX_BODY_MB") + .ok() + .and_then(|v| v.parse::().ok()) + .map(|mb| mb * 1024 * 1024) + .unwrap_or(128 * 1024 * 1024); + + Router::new() + .route("/health", get(health)) + .route("/api/status", get(api_status)) + .route("/api/invoke", post(invoke_handler)) + // Multipart file import — same protocol as the agent-gateway WebUI. + // Body limit is governed by max_body_bytes below (default 128MB). + .route("/api/files/import", post(import_files_handler)) + .route("/ws", get(ws_handler)) + // BFF 出网反代:复用本地反代的 handler,把出网统一收敛到主服务端口, + // 浏览器同源请求即可,无 CORS/随机端口问题(agent-gateway 同款架构)。 + // 注意 axum 0.8 中 `/proxy/{provider}` 与 `/{*rest}` 都不匹配尾斜杠路径, + // 显式补 `/proxy/{provider}/`,否则 `/proxy/hub/` 会落进 SPA fallback。 + .route("/image-proxy", get(handle_image_proxy)) + .route("/proxy/{provider}", any(handle_proxy)) + .route("/proxy/{provider}/", any(handle_proxy)) + .route("/proxy/{provider}/{*rest}", any(handle_proxy)) + .route("/", get(serve_root)) + .route("/{*path}", get(serve_static)) + .with_state(state) + .layer(middleware::from_fn_with_state(auth, auth_middleware)) + .layer(middleware::from_fn_with_state(limiter, rate_limit_middleware)) + // Outermost: enforce same-origin / allow-list before anything else. + .layer(middleware::from_fn_with_state(cors_config, cors_origin_middleware)) + // Raise the body limit for base64 file uploads inside /api/invoke + // (and /proxy bodies). Default 128MB, override via + // LIVEAGENT_HEADLESS_MAX_BODY_MB. + .layer(DefaultBodyLimit::max(max_body_bytes)) +} + +/// Build the axum state (registries that are not part of AppContext). +pub fn build_state( + ctx: Arc, + emitter: Arc, + proxy_base_url: String, + api_token: Option, +) -> Result { + let mcp_runtime = Arc::new(crate::commands::mcp::McpRuntimeManager::default()); + let shell_runs = Arc::new(ShellRunRegistry::default()); + let hook_scopes = Arc::new(crate::commands::hook::HookScopeRegistry::default()); + let proxy_server = crate::services::proxy::start_proxy_server()?; + Ok(HeadlessState { + ctx, + emitter, + mcp_runtime, + shell_runs, + hook_scopes, + proxy_server, + proxy_base_url, + api_token, + }) +} + +/// Run the headless server. Config via environment variables: +/// LIVEAGENT_HEADLESS_PORT (default 17890) +/// LIVEAGENT_HEADLESS_HOST (default 127.0.0.1) +/// LIVEAGENT_API_TOKEN (optional; enables Bearer auth) +/// LIVEAGENT_WEB_ROOT (optional; override WebUI dist path) +pub async fn serve() -> Result<(), String> { + let port = std::env::var("LIVEAGENT_HEADLESS_PORT") + .ok().and_then(|p| p.parse::().ok()).unwrap_or(17890); + let host = std::env::var("LIVEAGENT_HEADLESS_HOST") + .unwrap_or_else(|_| "127.0.0.1".to_string()); + + let (tx, _) = broadcast::channel(1024); + let emitter = Arc::new(WsEventEmitter::new(tx)); + let ws_emitter = Arc::clone(&emitter); + let emitter_dyn: Arc = ws_emitter; + + // Initialize (aligned with desktop setup): history DB migration, staging GC, builtin skills. + crate::commands::history_db::initialize_history_db() + .map_err(|e| format!("history db init: {e}"))?; + if let Err(error) = crate::commands::settings::initialize_system_proxy_from_db() { + eprintln!("failed to initialize system proxy state: {error}"); + } + crate::commands::system::gc_upload_staging_on_startup(); + if let Err(error) = crate::services::skills::ensure_builtin_agent_skills_sync() { + eprintln!("failed to seed builtin skills: {error}"); + } + + let ctx = AppContext::new(emitter_dyn); + // BFF:反代路由挂在主服务上,前端拿到的反代 baseUrl 即主服务地址。 + let proxy_base_url = format!("http://127.0.0.1:{port}"); + let auth_config = AuthConfig::from_env(); + let state = build_state( + ctx, + emitter, + proxy_base_url, + auth_config.api_token.clone(), + ).map_err(|e| format!("headless state: {e}"))?; + let app = build_router(state); + let listener = tokio::net::TcpListener::bind((host.as_str(), port)) + .await.map_err(|e| format!("bind {host}:{port}: {e}"))?; + + let has_auth = auth_config.api_token.is_some(); + eprintln!("LiveAgent headless listening on http://{host}:{port} (auth={has_auth})"); + // Security hint: bound to a non-loopback interface without a token means + // anyone who can reach this port can invoke commands without credentials. + let non_loopback = host != "127.0.0.1" && host != "localhost" && host != "::1"; + if non_loopback && !has_auth { + eprintln!( + "WARNING: listening on {host} with auth DISABLED. Set LIVEAGENT_API_TOKEN to \ + require credentials for remote /api/invoke and /ws callers." + ); + } + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await.map_err(|e| e.to_string()) +} + +#[cfg(feature = "runtime-fallback")] +fn web_root() -> Option { + if let Ok(root) = std::env::var("LIVEAGENT_WEB_ROOT") { + let root = PathBuf::from(root); + if root.is_dir() { return Some(root); } + eprintln!("LiveAgent headless: LIVEAGENT_WEB_ROOT={} not found, falling back", root.display()); + } + [PathBuf::from("../dist"), PathBuf::from("dist")].into_iter().find(|c| c.is_dir()) +} + +#[cfg(feature = "runtime-fallback")] +fn runtime_mime_for_path(path: &str) -> &'static str { + match path.rsplit('.').next() { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") | Some("mjs") => "application/javascript; charset=utf-8", + Some("json") => "application/json", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Map}; + + #[test] + fn camelize_snake_to_camel() { + assert_eq!(camelize("page_size"), "pageSize"); + assert_eq!(camelize("single"), "single"); + assert_eq!(camelize(""), ""); + } + + #[test] + fn remove_arg_snake_and_camel() { + let mut o = Map::new(); o.insert("page_size".into(), json!(10)); + assert_eq!(remove_arg(&mut o, "page_size"), Some(json!(10))); + let mut o = Map::new(); o.insert("pageSize".into(), json!(20)); + assert_eq!(remove_arg(&mut o, "page_size"), Some(json!(20))); + } + + #[test] + fn take_arg_missing_returns_business_error() { + let mut a = json!({}); + let err = take_arg::(&mut a, "x").unwrap_err(); + assert!(matches!(err, HeadlessError::Business(_))); + } + + #[test] + fn rate_limiter_basic() { + let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60)); + assert!(limiter.allow("ip1")); + assert!(limiter.allow("ip1")); + assert!(limiter.allow("ip1")); + assert!(!limiter.allow("ip1")); // exhausted + assert!(limiter.allow("ip2")); // different key + } +} + diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index ee7b77a52..e8a5dd045 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -1,831 +1,52 @@ +// Headless build (`--no-default-features`) runs the same business code over +// an axum HTTP/WebSocket bridge instead of a Tauri window. A small amount of +// dead code allowance is kept while the headless surface stabilizes. +#![cfg_attr(not(feature = "desktop"), allow(dead_code))] + +mod app_context; mod commands; +mod compat; +mod events; mod runtime; mod services; - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::Emitter; -use tauri::Manager; -use tauri::WindowEvent; - -const MAIN_WINDOW_LABEL: &str = "main"; -// Only size + maximized are persisted: POSITION would fight multi-monitor -// layouts we don't manage, VISIBLE would re-show a tray-hidden window on -// startup, and DECORATIONS would override the per-platform window chrome -// (Windows runs undecorated with custom chrome). -pub(crate) const WINDOW_STATE_FLAGS: tauri_plugin_window_state::StateFlags = - tauri_plugin_window_state::StateFlags::SIZE - .union(tauri_plugin_window_state::StateFlags::MAXIMIZED); -const TRAY_SHOW_MENU_ON_LEFT_CLICK: bool = !cfg!(target_os = "windows"); -const TERMINAL_EXIT_REQUESTED_EVENT: &str = "terminal:exit-requested"; -/// 统一的「前端动作」事件:托盘菜单与全局快捷键中需要前端语义的动作 -/// (开会话/新建对话/切工作空间/改主题/停止运行等)都经此事件转发, -/// 两端各自监听并只处理自己拥有的 action(App.tsx / ChatPage.tsx)。 -const APP_ACTION_EVENT: &str = "app:action"; -/// Rust 直连动作的结果反馈(如托盘触发 cron):前端收到后 toast 呈现。 -const APP_ACTION_FEEDBACK_EVENT: &str = "app:action-feedback"; - -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct TerminalExitRequestedEvent { - running_count: usize, -} +// Desktop-only Tauri runtime. Compiled only when the `desktop` feature is on +// (the default). The headless build (`--no-default-features`) skips it and +// uses the axum server in `headless`. +#[cfg(feature = "desktop")] +mod desktop; +// Headless-only axum HTTP/WebSocket runtime. Compiled only when the `desktop` +// feature is off. +#[cfg(not(feature = "desktop"))] +mod headless; pub fn app_version() -> &'static str { env!("LIVEAGENT_APP_VERSION") } -macro_rules! app_invoke_handler { - () => { - tauri::generate_handler![ - // Chat history - commands::chat_history::chat_history_list, - commands::chat_history::chat_history_workdirs, - commands::chat_history::chat_history_shared_list, - commands::chat_history::chat_history_search, - commands::chat_history::chat_history_get_window, - commands::chat_history::chat_history_upsert, - commands::chat_history::chat_history_upsert_active_segment, - commands::chat_history::chat_history_append_segment, - commands::chat_history::chat_history_rename, - commands::chat_history::chat_history_branch, - commands::chat_history::chat_history_replace_from_message, - commands::chat_history::chat_history_set_pinned, - commands::chat_history::chat_history_set_model, - commands::chat_history::chat_history_share_get, - commands::chat_history::chat_history_share_set, - commands::chat_history::chat_history_delete, - // Subagent store - commands::subagent_store::subagent_identity_upsert, - commands::subagent_store::subagent_identity_list, - commands::subagent_store::subagent_run_save, - commands::subagent_store::subagent_run_list, - commands::subagent_store::subagent_run_load, - commands::subagent_store::subagent_run_prune, - commands::subagent_store::subagent_message_append, - commands::subagent_store::subagent_message_list, - // File system - commands::fs::fs_read_text, - commands::fs::fs_read_editable_text, - commands::fs::fs_path_status, - commands::fs::fs_read_image_source, - commands::fs::fs_read_workspace_image, - commands::fs::fs_write_text, - commands::fs::fs_edit_text, - commands::fs::fs_delete, - commands::fs::fs_open_workspace_path, - commands::fs::fs_create_dir, - commands::fs::fs_rename, - commands::fs::fs_roots, - commands::fs::fs_list_dirs, - commands::fs::fs_list, - commands::fs::fs_glob, - commands::fs::fs_grep, - commands::fs::fs_mention_list, - commands::chat_file_links::open_chat_file_link, - // Subagent worktrees - commands::subagent_worktree::subagent_worktree_create, - commands::subagent_worktree::subagent_worktree_status, - commands::subagent_worktree::subagent_worktree_apply, - commands::subagent_worktree::subagent_worktree_cleanup, - // MCP - commands::mcp::mcp_list_tools, - commands::mcp::mcp_call_tool, - commands::mcp::mcp_runtime_status, - commands::mcp::mcp_stop_server, - commands::mcp::mcp_test_server, - commands::mcp::mcp_restart_server, - // Memory - commands::memory::memory_list, - commands::memory::memory_read, - commands::memory::memory_search, - commands::memory::memory_write, - commands::memory::memory_update, - commands::memory::memory_delete, - commands::memory::memory_delete_project, - commands::memory::memory_accept, - commands::memory::memory_apply_batch, - commands::memory::memory_organize_run_create, - commands::memory::memory_organize_run_update, - commands::memory::memory_organize_run_list, - commands::memory::memory_organize_run_read, - commands::memory::memory_organize_run_clear_history, - commands::memory::memory_organize_due_claim, - commands::memory::memory_organize_due_complete, - commands::memory::memory_index_overview, - commands::memory::memory_paths_info, - commands::memory::memory_recent_rejections, - commands::memory::memory_today_local_date, - commands::memory::memory_today_daily, - commands::memory::memory_quota_summary, - commands::memory::memory_wipe_all, - // Settings - commands::settings::settings_load_all, - commands::settings::settings_save_providers, - commands::settings::settings_list_ccswitch_providers, - commands::settings::settings_list_cherry_studio_providers, - commands::settings::settings_list_cherry_studio_providers_from_path, - commands::settings::settings_save_system, - commands::settings::settings_save_mcp, - commands::settings::settings_save_agents, - commands::settings::settings_save_ssh, - commands::settings::settings_apply_ssh_patch, - commands::settings::settings_reset_ssh_known_host, - commands::settings::settings_save_remote, - commands::settings::settings_save_memory, - commands::update::app_update_check, - commands::update::app_update_install, - commands::update::app_restart, - commands::app::app_runtime_platform, - commands::app::app_set_close_window_behavior, - commands::app::app_set_global_shortcuts, - commands::app::app_window_pinned, - commands::app::app_toggle_window_pin, - commands::app::app_confirmed_exit, - commands::app::app_macos_traffic_light_metrics, - commands::tray::app_tray_menu_sync, - // Hooks - commands::hook::hook_run_script, - commands::hook::hook_run_http_requests, - commands::hook::hook_cancel_scope, - // Automation (cron tasks + hooks store) - commands::cron::cron_validate_expression, - commands::cron::automation_snapshot, - commands::cron::automation_cron_apply, - commands::cron::automation_hooks_apply, - commands::cron::automation_list_runs, - commands::cron::automation_clear_runs, - commands::cron::automation_run_cron_now, - commands::cron::automation_claim_prompt_runs, - commands::cron::automation_release_prompt_run, - commands::cron::automation_complete_prompt_run, - // Local command execution - commands::shell::shell_run, - commands::shell::runtime_cancel, - commands::process::managed_process_start, - commands::process::managed_process_status, - commands::process::managed_process_stop, - commands::process::managed_process_read_log, - commands::process::managed_process_snapshot, - commands::process::managed_process_clear, - commands::terminal::terminal_shell_options, - commands::terminal::terminal_list, - commands::terminal::terminal_create, - commands::terminal::terminal_create_ssh, - commands::terminal::terminal_answer_ssh_prompt, - commands::terminal::terminal_cancel_ssh_prompt, - commands::terminal::terminal_ssh_reconnect, - commands::terminal::terminal_ssh_latency, - commands::terminal::terminal_ssh_exec, - commands::terminal::terminal_ssh_local_forward_start, - commands::terminal::terminal_ssh_local_forward_list, - commands::terminal::terminal_ssh_local_forward_stop, - commands::terminal::terminal_ssh_local_forward_check_port, - commands::terminal::ssh_terminal_tabs_list, - commands::terminal::ssh_terminal_tab_open, - commands::terminal::ssh_terminal_tab_close, - commands::terminal::terminal_stream_attach, - commands::terminal::terminal_stream_input, - commands::terminal::terminal_stream_resize, - commands::terminal::terminal_rename, - commands::terminal::terminal_close, - commands::terminal::terminal_close_project, - commands::terminal::terminal_read_tail, - commands::sftp::sftp_list, - commands::sftp::sftp_stat, - commands::sftp::sftp_read_text, - commands::sftp::sftp_write_text, - commands::sftp::sftp_mkdir, - commands::sftp::sftp_rename, - commands::sftp::sftp_delete, - commands::sftp::sftp_transfer, - commands::sftp::sftp_cancel_transfer, - commands::sftp::sftp_transfer_status, - commands::git::git_status, - commands::git::git_discover_repositories, - commands::git::git_branches, - commands::git::git_init, - commands::git::git_clone_repository, - commands::git::git_clone_repository_start, - commands::git::git_clone_repository_tasks, - commands::git::git_clone_repository_cancel, - commands::git::git_clone_repository_dismiss, - commands::git::git_list_remote_branches, - commands::git::git_switch_branch, - commands::git::git_create_branch, - commands::git::git_diff, - commands::git::git_log, - commands::git::git_commit_details, - commands::git::git_compare_commit_with_remote, - commands::git::git_commit_diff, - commands::git::git_stage, - commands::git::git_stage_all, - commands::git::git_unstage, - commands::git::git_unstage_all, - commands::git::git_discard, - commands::git::git_discard_all, - commands::git::git_add_to_gitignore, - commands::git::git_open_system_file_location, - commands::git::git_commit, - commands::git::git_fetch, - commands::git::git_pull, - commands::git::git_set_remote, - commands::git::git_push, - commands::git::git_delete_branch, - commands::git::git_rename_branch, - commands::git::git_stash_push, - commands::git::git_stash_pop, - commands::system::system_pick_folder, - commands::system::system_pick_file, - commands::system::system_create_project_folder, - commands::system::system_import_pasted_texts, - commands::system::system_import_readable_file_paths, - commands::system::system_import_uploaded_readable_files, - commands::system::system_pick_readable_files, - commands::system::system_read_uploaded_image_preview, - commands::system::system_read_uploaded_native_attachment, - commands::system::system_list_skill_files, - commands::system::system_ensure_builtin_skills, - commands::system::system_read_skill_metadata, - commands::system::system_read_skill_text, - commands::system::system_manage_skill, - commands::system::system_append_debug_jsonl, - commands::system::system_begin_power_activity, - commands::system::system_end_power_activity, - commands::system::system_clipboard_read_text, - commands::gateway::gateway_connect, - commands::gateway::gateway_disconnect, - commands::gateway::gateway_status, - commands::gateway::gateway_nudge_connection, - commands::gateway::gateway_send_chat_ingress_batch, - commands::gateway::gateway_commit_chat_checkpoint, - commands::gateway::gateway_chat_claim_next, - commands::gateway::gateway_chat_mark_started, - commands::gateway::gateway_chat_mark_local_started, - commands::gateway::gateway_chat_mark_local_cancelled, - commands::gateway::gateway_chat_mark_queued_in_gui, - commands::gateway::gateway_chat_complete, - commands::gateway::gateway_chat_fail, - commands::gateway::gateway_chat_cancel_request, - commands::gateway::gateway_chat_heartbeat, - commands::gateway::gateway_chat_runtime_heartbeat, - commands::gateway::gateway_chat_release_lease, - commands::gateway::gateway_chat_queue_respond, - commands::gateway::gateway_publish_chat_queue_event, - commands::gateway::gateway_publish_settings_sync, - commands::gateway::gateway_tunnel_state, - commands::gateway::gateway_tunnel_create, - commands::gateway::gateway_tunnel_update, - commands::gateway::gateway_tunnel_close, - commands::gateway::gateway_tunnel_check, - commands::gateway::workspace_watch_set, - commands::gateway::provider_usage_query, - commands::gateway::provider_usage_test, - services::proxy::proxy_get_server_info, - ] - }; -} - -fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - window.show()?; - window.unminimize()?; - window.set_focus()?; - } - - Ok(()) -} - -fn toggle_main_window(app: &tauri::AppHandle) { - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - let visible = window.is_visible().unwrap_or(false); - let focused = window.is_focused().unwrap_or(false); - if visible && focused { - let _ = window.hide(); - } else if let Err(error) = show_main_window(app) { - eprintln!("failed to show LiveAgent window from global shortcut: {error}"); - } - } -} - -fn toggle_main_window_pin(app: &tauri::AppHandle) { - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - let pin_state = app.state::>(); - let next = !pin_state.0.load(Ordering::SeqCst); - match window.set_always_on_top(next) { - Ok(()) => { - pin_state.0.store(next, Ordering::SeqCst); - if next { - if let Err(error) = show_main_window(app) { - eprintln!("failed to show LiveAgent window when pinning: {error}"); - } - } - let _ = app.emit("global-shortcut:pin-changed", next); - // 托盘勾选与置顶真源(WindowPinState)同步;托盘可能尚未建好。 - if let Some(handles) = app.try_state::>() { - handles.set_pin_checked(next); - } - } - Err(error) => eprintln!("failed to toggle LiveAgent window pin: {error}"), - } - } -} - -/// 应用级动作总线:全局快捷键与托盘菜单的动作都收敛到这里执行。 -/// Rust 能独立完成的直接做(webview 卡死时托盘仍可用);需要前端语义的 -/// 经 [`APP_ACTION_EVENT`] 转发(部分动作先呼出主窗口)。 -#[derive(Debug, Clone)] -enum AppAction { - Summon, - ToggleWindow, - TogglePin, - NewChat, - OpenConversation(String), - ViewAllConversations, - SwitchWorkspace(String), - StopRun(String), - StopAllRuns, - ToggleCronTask(String), - GatewayToggle, - SetTheme(&'static str), - OpenSettings, - CheckUpdates, - OpenDataDir, - Quit, -} - -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct AppActionEvent { - action: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - value: Option, -} - -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct AppActionFeedbackEvent { - action: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - /// 结果附加值(如 cron 开关后的 "enabled"/"disabled")。 - #[serde(skip_serializing_if = "Option::is_none")] - value: Option, -} +/// `WINDOW_STATE_FLAGS` lives in `desktop` (it references Tauri types); keep +/// the `crate::WINDOW_STATE_FLAGS` path used by `commands::app::update`. +#[cfg(feature = "desktop")] +pub(crate) use desktop::WINDOW_STATE_FLAGS; -/// 托盘菜单项 ID → 动作。静态 ID 与动态前缀都定义在 `services::tray`。 -fn tray_menu_action(id: &str) -> Option { - use services::tray as tray_ids; - match id { - tray_ids::TRAY_SHOW_ID => Some(AppAction::Summon), - tray_ids::TRAY_NEW_CHAT_ID => Some(AppAction::NewChat), - tray_ids::TRAY_PIN_ID => Some(AppAction::TogglePin), - tray_ids::TRAY_RECENT_VIEW_ALL_ID => Some(AppAction::ViewAllConversations), - tray_ids::TRAY_RUN_STOP_ALL_ID => Some(AppAction::StopAllRuns), - tray_ids::TRAY_GATEWAY_ID => Some(AppAction::GatewayToggle), - tray_ids::TRAY_THEME_LIGHT_ID => Some(AppAction::SetTheme("light")), - tray_ids::TRAY_THEME_DARK_ID => Some(AppAction::SetTheme("dark")), - tray_ids::TRAY_THEME_SYSTEM_ID => Some(AppAction::SetTheme("system")), - tray_ids::TRAY_SETTINGS_ID => Some(AppAction::OpenSettings), - tray_ids::TRAY_CHECK_UPDATES_ID => Some(AppAction::CheckUpdates), - tray_ids::TRAY_OPEN_DATA_DIR_ID => Some(AppAction::OpenDataDir), - tray_ids::TRAY_QUIT_ID => Some(AppAction::Quit), - _ => { - if let Some(rest) = id.strip_prefix(tray_ids::TRAY_RECENT_PREFIX) { - Some(AppAction::OpenConversation(rest.to_string())) - } else if let Some(rest) = id.strip_prefix(tray_ids::TRAY_WORKSPACE_PREFIX) { - Some(AppAction::SwitchWorkspace(rest.to_string())) - } else if let Some(rest) = id.strip_prefix(tray_ids::TRAY_RUN_PREFIX) { - Some(AppAction::StopRun(rest.to_string())) - } else { - id.strip_prefix(tray_ids::TRAY_CRON_PREFIX) - .map(|rest| AppAction::ToggleCronTask(rest.to_string())) - } - } - } -} +/// Desktop entry point: full Tauri runtime (window, tray, shortcuts...). +#[cfg(feature = "desktop")] +pub use desktop::run; -/// 转发前端动作。`show_window` 用于用户预期看到界面反馈的动作 -/// (开会话/新建对话/打开设置等);后台型动作(停止运行/改主题/网关开关) -/// 不抢焦点。 -fn forward_app_action( - app: &tauri::AppHandle, - action: &'static str, - id: Option, - value: Option, - show_window: bool, -) { - if show_window { - if let Err(error) = show_main_window(app) { - eprintln!("failed to show LiveAgent window for action {action}: {error}"); - } - } - if let Err(error) = app.emit(APP_ACTION_EVENT, AppActionEvent { action, id, value }) { - eprintln!("failed to emit app action {action}: {error}"); - } -} - -fn dispatch_app_action(app: &tauri::AppHandle, action: AppAction) { - match action { - AppAction::Summon => { - if let Err(error) = show_main_window(app) { - eprintln!("failed to show LiveAgent window: {error}"); - } - } - AppAction::ToggleWindow => toggle_main_window(app), - AppAction::TogglePin => toggle_main_window_pin(app), - AppAction::NewChat => forward_app_action(app, "new-chat", None, None, true), - AppAction::OpenConversation(id) => { - forward_app_action(app, "open-conversation", Some(id), None, true); - } - AppAction::ViewAllConversations => { - forward_app_action(app, "view-all-conversations", None, None, true); - } - AppAction::SwitchWorkspace(id) => { - forward_app_action(app, "switch-workspace", Some(id), None, true); - } - AppAction::StopRun(id) => forward_app_action(app, "stop-run", Some(id), None, false), - AppAction::StopAllRuns => forward_app_action(app, "stop-all-runs", None, None, false), - AppAction::GatewayToggle => forward_app_action(app, "gateway-toggle", None, None, false), - AppAction::SetTheme(theme) => { - forward_app_action(app, "set-theme", None, Some(theme.to_string()), false); - } - AppAction::OpenSettings => forward_app_action(app, "open-settings", None, None, true), - AppAction::CheckUpdates => forward_app_action(app, "check-updates", None, None, true), - AppAction::ToggleCronTask(task_id) => { - // 托盘的定时任务子项是启用开关:翻转走 AutomationStore 唯一的 - // cron_apply 写路径(CAS),成功后 automation:cron-changed 会驱动 - // 前端 store 与托盘勾选自然刷新。开关是后台动作,不呼出主窗口; - // 结果经 feedback 事件给前端 toast(窗口可见时提示文案)。 - let Some(store) = app.try_state::>() else { - return; - }; - let store = Arc::clone(store.inner()); - let app_handle = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - let (value, error) = match store.toggle_cron_task_enabled(&task_id) { - Ok(enabled) => ( - Some(if enabled { "enabled" } else { "disabled" }.to_string()), - None, - ), - Err(error) => { - eprintln!("failed to toggle cron task from tray: {error}"); - (None, Some(error)) - } - }; - if let Err(emit_error) = app_handle.emit( - APP_ACTION_FEEDBACK_EVENT, - AppActionFeedbackEvent { - action: "toggle-cron-task", - id: Some(task_id), - ok: error.is_none(), - error, - value, - }, - ) { - eprintln!("failed to emit cron toggle feedback: {emit_error}"); - } - }); - } - AppAction::OpenDataDir => { - use tauri_plugin_opener::OpenerExt; - match commands::settings::config_dir() { - Ok(dir) => { - if let Err(error) = app - .opener() - .open_path(dir.to_string_lossy().to_string(), None::<&str>) - { - eprintln!("failed to open LiveAgent data directory: {error}"); - } - } - Err(error) => eprintln!("failed to resolve LiveAgent data directory: {error}"), - } - } - AppAction::Quit => { - let allow_exit = app.state::>(); - let terminal_registry = app.state::>(); - request_app_exit(app, allow_exit.inner(), terminal_registry.inner()); +/// Headless entry point (built with `--no-default-features`): same business +/// code as the desktop build, served over an axum HTTP/WebSocket bridge +/// (`/health`, `/api/invoke`, `/ws`) on `LIVEAGENT_HEADLESS_PORT` (default +/// 17890). See `src/headless.rs`. +#[cfg(not(feature = "desktop"))] +pub fn run() { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(error) => { + eprintln!("failed to start tokio runtime: {error}"); + std::process::exit(1); } - } -} - -fn handle_global_shortcut( - app: &tauri::AppHandle, - shortcut: &tauri_plugin_global_shortcut::Shortcut, -) { - let action = app - .state::>() - .lookup_action(shortcut); - let Some(action) = action else { - return; - }; - let action = match action.as_str() { - "summon" => AppAction::Summon, - "toggle" => AppAction::ToggleWindow, - "newChat" => AppAction::NewChat, - "pin" => AppAction::TogglePin, - _ => return, }; - dispatch_app_action(app, action); -} - -fn request_app_exit( - app: &tauri::AppHandle, - allow_exit: &AtomicBool, - terminal_registry: &runtime::terminal::TerminalSessionRegistry, -) { - let running_count = terminal_registry.running_session_count(); - if running_count > 0 { - if let Err(error) = show_main_window(app) { - eprintln!("failed to show LiveAgent window before terminal exit confirm: {error}"); - } - if let Err(error) = app.emit( - TERMINAL_EXIT_REQUESTED_EVENT, - TerminalExitRequestedEvent { running_count }, - ) { - eprintln!("failed to request terminal exit confirmation: {error}"); - } - return; - } - - allow_exit.store(true, Ordering::SeqCst); - app.exit(0); -} - -fn configure_system_tray(app: &tauri::App) -> tauri::Result<()> { - let skeleton = services::tray::build_tray_menu_skeleton(app, app_version())?; - let menu = skeleton.menu.clone(); - - let mut tray_builder = TrayIconBuilder::new() - .tooltip("LiveAgent") - .menu(&menu) - .show_menu_on_left_click(TRAY_SHOW_MENU_ON_LEFT_CLICK) - .on_menu_event(|app, event| { - if let Some(action) = tray_menu_action(event.id().as_ref()) { - dispatch_app_action(app, action); - } - }) - .on_tray_icon_event(|tray, event| match event { - TrayIconEvent::DoubleClick { - button: MouseButton::Left, - .. - } => { - if let Err(error) = show_main_window(tray.app_handle()) { - eprintln!("failed to show LiveAgent window from tray double-click: {error}"); - } - } - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Down, - .. - } => { - // Windows 惯例:左键单击即激活主窗口(菜单在右键)。 - // 其他平台左键弹菜单(TRAY_SHOW_MENU_ON_LEFT_CLICK)。 - if cfg!(target_os = "windows") { - if let Err(error) = show_main_window(tray.app_handle()) { - eprintln!("failed to show LiveAgent window from tray click: {error}"); - } - } - } - _ => {} - }); - - #[cfg(target_os = "macos")] - { - match tauri::image::Image::from_bytes(include_bytes!("../icons/tray-icon-macos.png")) { - Ok(icon) => { - tray_builder = tray_builder.icon(icon).icon_as_template(true); - } - Err(error) => { - eprintln!("failed to load macOS tray icon: {error}"); - if let Some(icon) = app.default_window_icon() { - tray_builder = tray_builder.icon(icon.clone()); - } - } - } + if let Err(error) = rt.block_on(crate::headless::serve()) { + eprintln!("headless server error: {error}"); + std::process::exit(1); } - - #[cfg(not(target_os = "macos"))] - { - if let Some(icon) = app.default_window_icon() { - tray_builder = tray_builder.icon(icon.clone()); - } - } - - let tray = tray_builder.build(app)?; - let handles = Arc::new(services::tray::TrayMenuHandles::new( - skeleton, - tray.clone(), - app_version(), - )); - app.manage(tray); - app.manage(handles); - - Ok(()) -} - -#[cfg(target_os = "windows")] -fn configure_windows_window_chrome(app: &tauri::App) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - window.set_decorations(false)?; - } - - Ok(()) -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - let automation_store = Arc::new( - services::automation::AutomationStore::open() - .expect("failed to initialize LiveAgent automation store"), - ); - let automation_scheduler = Arc::new(services::automation::AutomationScheduler::new( - Arc::clone(&automation_store), - )); - let memory_store = Arc::new( - services::memory::MemoryStore::open().expect("failed to initialize LiveAgent memory store"), - ); - let provider_usage_service = - Arc::new(services::provider_usage::ProviderUsageService::default()); - let power_activity = Arc::new(services::power_activity::PowerActivityManager::default()); - let managed_process_registry = - Arc::new(runtime::managed_process::ManagedProcessRegistry::open()); - let terminal_registry = Arc::new(runtime::terminal::TerminalSessionRegistry::default()); - let git_clone_task_registry = Arc::new(commands::git::GitCloneTaskRegistry::default()); - let sftp_registry = Arc::new(runtime::sftp::SftpSessionRegistry::new(Arc::clone( - &terminal_registry, - ))); - let allow_exit = Arc::new(AtomicBool::new(false)); - let close_window_behavior = Arc::new(commands::app::CloseWindowBehaviorState::new( - commands::app::CLOSE_WINDOW_BEHAVIOR_MINIMIZE, - )); - - let app = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_mcp_bridge::init()) - .plugin( - tauri_plugin_window_state::Builder::new() - .with_state_flags(WINDOW_STATE_FLAGS) - .build(), - ) - .plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler(|app, shortcut, event| { - if event.state() == tauri_plugin_global_shortcut::ShortcutState::Pressed { - handle_global_shortcut(app, shortcut); - } - }) - .build(), - ) - .manage(Arc::new(commands::app::GlobalShortcutRegistry::default())) - .manage(Arc::new(commands::app::WindowPinState::default())) - .manage(Arc::new(commands::mcp::McpRuntimeManager::default())) - .manage(Arc::clone(&memory_store)) - .manage(Arc::clone(&provider_usage_service)) - .manage(Arc::clone(&power_activity)) - .manage(Arc::new(runtime::shell_runner::ShellRunRegistry::default())) - .manage(Arc::clone(&managed_process_registry)) - .manage(Arc::clone(&terminal_registry)) - .manage(Arc::clone(&sftp_registry)) - .manage(Arc::clone(&git_clone_task_registry)) - .manage(Arc::clone(&allow_exit)) - .manage(Arc::clone(&close_window_behavior)) - .manage(Arc::clone(&automation_store)) - .manage(Arc::clone(&automation_scheduler)) - .manage(Arc::new(commands::hook::HookScopeRegistry::default())) - .setup({ - let terminal_registry = Arc::clone(&terminal_registry); - let sftp_registry = Arc::clone(&sftp_registry); - let managed_process_registry = Arc::clone(&managed_process_registry); - let git_clone_task_registry = Arc::clone(&git_clone_task_registry); - let provider_usage_service = Arc::clone(&provider_usage_service); - move |app| { - commands::history_db::initialize_history_db()?; - configure_system_tray(app)?; - #[cfg(target_os = "windows")] - configure_windows_window_chrome(app)?; - if let Err(error) = commands::settings::initialize_system_proxy_from_db() { - eprintln!("failed to initialize system proxy state: {error}"); - } - commands::system::gc_upload_staging_on_startup(); - app.manage(services::proxy::start_proxy_server()?); - if let Err(error) = services::skills::ensure_builtin_agent_skills_sync() { - eprintln!("failed to seed builtin skills: {error}"); - } - terminal_registry.attach_app_handle(app.handle().clone()); - sftp_registry.attach_app_handle(app.handle().clone()); - let gateway_controller = Arc::new(services::gateway::GatewayController::new( - app.handle().clone(), - Arc::clone(&automation_store), - Arc::clone(&memory_store), - Arc::clone(&provider_usage_service), - Arc::clone(&terminal_registry), - Arc::clone(&sftp_registry), - Arc::clone(&managed_process_registry), - Arc::clone(&git_clone_task_registry), - )); - managed_process_registry.set_notifier( - runtime::managed_process::ManagedProcessNotifier { - app_handle: app.handle().clone(), - gateway: Arc::downgrade(&gateway_controller), - }, - ); - managed_process_registry.spawn_startup_reconcile(); - managed_process_registry.spawn_monitor(); - automation_store.set_notifier(services::automation::AutomationNotifier { - app_handle: app.handle().clone(), - gateway: Arc::downgrade(&gateway_controller), - scheduler: Arc::downgrade(&automation_scheduler), - }); - Arc::clone(&automation_scheduler).start(); - app.manage(Arc::clone(&gateway_controller)); - if let Err(error) = gateway_controller.start() { - eprintln!("failed to start remote gateway controller: {error}"); - } - tauri::async_runtime::spawn({ - let gateway_controller = Arc::clone(&gateway_controller); - async move { - if let Err(error) = gateway_controller.reload_from_db().await { - eprintln!("failed to load remote gateway settings: {error}"); - } - } - }); - Ok(()) - } - }) - .on_window_event({ - let allow_exit = Arc::clone(&allow_exit); - let close_window_behavior = Arc::clone(&close_window_behavior); - let terminal_registry = Arc::clone(&terminal_registry); - move |window, event| { - if window.label() != MAIN_WINDOW_LABEL { - return; - } - - if let WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - if commands::app::is_close_window_exit(&close_window_behavior) { - request_app_exit(window.app_handle(), &allow_exit, &terminal_registry); - } else if let Err(error) = window.hide() { - eprintln!("failed to hide LiveAgent window on close: {error}"); - } - } - } - }) - .invoke_handler(app_invoke_handler!()) - .build(tauri::generate_context!()) - .expect("error while building tauri application"); - - app.run(move |_app, event| match event { - tauri::RunEvent::Resumed => { - if let Some(gateway_controller) = - _app.try_state::>() - { - if let Err(error) = gateway_controller.nudge_connection("app_resumed", true) { - eprintln!("failed to nudge gateway connection after app resume: {error}"); - } - } - } - #[cfg(target_os = "macos")] - tauri::RunEvent::Reopen { .. } => { - if let Err(error) = show_main_window(_app) { - eprintln!("failed to show LiveAgent window from dock reopen: {error}"); - } - } - tauri::RunEvent::ExitRequested { api, .. } => { - if !allow_exit.load(Ordering::SeqCst) { - let running_count = terminal_registry.running_session_count(); - if running_count > 0 { - if let Err(error) = show_main_window(_app) { - eprintln!( - "failed to show LiveAgent window before terminal exit confirm: {error}" - ); - } - if let Err(error) = _app.emit( - TERMINAL_EXIT_REQUESTED_EVENT, - TerminalExitRequestedEvent { running_count }, - ) { - eprintln!("failed to request terminal exit confirmation: {error}"); - } - } - api.prevent_exit(); - } else { - // Real exit: reclaim every non-isolated managed process - // before the OS tears us down (Drop is not guaranteed). - terminal_registry.shutdown_cleanup(); - managed_process_registry.shutdown_cleanup(); - git_clone_task_registry.shutdown_cleanup(); - power_activity.clear_all(); - } - } - _ => {} - }); } diff --git a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs index 01b29cf0f..8623e6301 100644 --- a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs +++ b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::Emitter; + use crate::runtime::managed_process_journal as journal; use crate::runtime::platform::{expand_tilde_path, strip_windows_verbatim_prefix}; @@ -20,6 +20,8 @@ use crate::runtime::process::{ }; use crate::runtime::shell_runner::spawn_platform_shell_command; use crate::services::gateway::GatewayController; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; const PROCESS_LOG_DIR: &str = "process-logs"; const DEFAULT_LOG_BYTES: u64 = 64 * 1024; @@ -38,21 +40,21 @@ pub const MANAGED_PROCESS_CHANGED_EVENT: &str = "managed-process:changed"; /// Fan-out target for registry mutations: every change emits the same full /// snapshot to the local webview and (when connected) to the gateway. pub struct ManagedProcessNotifier { - pub app_handle: tauri::AppHandle, + pub event_emitter: Arc, pub gateway: Weak, } impl ManagedProcessNotifier { fn changed(&self, snapshot: &ManagedProcessSnapshot) { if let Err(error) = self - .app_handle + .event_emitter .emit(MANAGED_PROCESS_CHANGED_EVENT, snapshot) { eprintln!("emit {MANAGED_PROCESS_CHANGED_EVENT} failed: {error}"); } if let Some(gateway) = self.gateway.upgrade() { let snapshot = snapshot.clone(); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { if let Err(error) = gateway.publish_managed_process_snapshot(snapshot).await { eprintln!("publish managed process snapshot failed: {error}"); } @@ -891,19 +893,38 @@ mod tests { } /// True while ANY member of the process group is alive, even after the - /// leader exited. + /// leader exited. Probes /proc states instead of `kill -0 -`: + /// kill(2) treats zombies as alive, and orphaned zombies are reaped by + /// PID 1 — which not every host does (minimal container images), leaving + /// `kill -0` to report a terminated group as alive forever. #[cfg(unix)] fn process_group_exists(pgid: u32) -> bool { - std::process::Command::new("kill") - .arg("-0") - .arg("--") - .arg(format!("-{pgid}")) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let Ok(pid) = name.parse::() else { + continue; + }; + let Ok(raw) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + continue; + }; + let Some(rest) = raw.split(')').last() else { + continue; + }; + let parts: Vec<&str> = rest.trim_start().split_whitespace().collect(); + // parts: [state, ppid, pgrp, ...] + if parts.len() > 2 + && parts[2].parse::().ok() == Some(pgid) + && !matches!(parts[0], "Z" | "X") + { + return true; + } + } + false } #[cfg(unix)] diff --git a/crates/agent-gui/src-tauri/src/runtime/sftp.rs b/crates/agent-gui/src-tauri/src/runtime/sftp.rs index e26335e69..2e9bd2150 100644 --- a/crates/agent-gui/src-tauri/src/runtime/sftp.rs +++ b/crates/agent-gui/src-tauri/src/runtime/sftp.rs @@ -6,7 +6,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Emitter}; + use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use uuid::Uuid; @@ -14,6 +14,8 @@ use crate::runtime::platform::expand_tilde_path; use crate::runtime::project_path::{ project_path_key as normalize_project_path_key, project_path_keys_equal, }; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; use crate::runtime::terminal::{ TerminalSessionRegistry, TerminalSftpConnection, TerminalSshSessionInfo, }; @@ -118,7 +120,7 @@ pub struct SftpSessionRegistry { sessions: Mutex>, transfers: Mutex>>, transfer_states: Mutex>, - app_handle: Mutex>, + event_emitter: Mutex>>, subscribers: Arc>>>, next_subscriber_id: AtomicUsize, } @@ -158,15 +160,15 @@ impl SftpSessionRegistry { sessions: Mutex::new(HashMap::new()), transfers: Mutex::new(HashMap::new()), transfer_states: Mutex::new(HashMap::new()), - app_handle: Mutex::new(None), + event_emitter: Mutex::new(None), subscribers: Arc::new(Mutex::new(HashMap::new())), next_subscriber_id: AtomicUsize::new(0), } } - pub fn attach_app_handle(&self, app_handle: AppHandle) { - if let Ok(mut slot) = self.app_handle.lock() { - *slot = Some(app_handle); + pub fn attach_event_emitter(&self, event_emitter: Arc) { + if let Ok(mut slot) = self.event_emitter.lock() { + *slot = Some(event_emitter); } } @@ -506,7 +508,7 @@ impl SftpSessionRegistry { let failed_template = initial.clone(); let registry = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = if direction == "upload" { registry .upload( @@ -634,9 +636,9 @@ impl SftpSessionRegistry { transfer_states.insert(key, payload.transfer.clone()); } - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(SFTP_EVENT_NAME, &payload); + if let Ok(event_emitter) = self.event_emitter.lock() { + if let Some(event_emitter) = event_emitter.as_ref() { + let _ = event_emitter.emit(SFTP_EVENT_NAME, &payload); } } diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs index 826694dd9..5bc227505 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs @@ -1,5 +1,6 @@ use std::sync::Arc; -use tauri::Emitter; +use crate::events::EventEmitterExt; + use super::*; @@ -27,9 +28,9 @@ impl TerminalSessionRegistry { ssh_local_forward: None, }; - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); + if let Ok(event_emitter) = self.event_emitter.lock() { + if let Some(event_emitter) = event_emitter.as_ref() { + let _ = event_emitter.emit(TERMINAL_EVENT_NAME, &payload); } } @@ -101,10 +102,10 @@ impl TerminalSessionRegistry { if payloads.is_empty() { return; } - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { + if let Ok(event_emitter) = self.event_emitter.lock() { + if let Some(event_emitter) = event_emitter.as_ref() { for payload in payloads { - let _ = app_handle.emit(TERMINAL_STREAM_EVENT_NAME, payload); + let _ = event_emitter.emit(TERMINAL_STREAM_EVENT_NAME, payload); } } } @@ -145,9 +146,9 @@ impl TerminalSessionRegistry { ssh_local_forward: None, }; - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); + if let Ok(event_emitter) = self.event_emitter.lock() { + if let Some(event_emitter) = event_emitter.as_ref() { + let _ = event_emitter.emit(TERMINAL_EVENT_NAME, &payload); } } diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs index 3b08f17a4..416a2d82f 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs @@ -18,7 +18,8 @@ use std::collections::HashMap; use std::sync::atomic::AtomicUsize; use std::sync::{mpsc, Arc, Mutex}; use std::time::Duration; -use tauri::AppHandle; +use crate::events::EventEmitter; + mod events; mod output; @@ -84,7 +85,7 @@ pub struct TerminalSessionRegistry { pending_ssh_prompts: Mutex>, ssh_terminal_tabs_tx: Mutex<()>, ssh_terminal_tabs: Mutex>, - app_handle: Mutex>, + event_emitter: Mutex>>, subscribers: Arc>>>, stream_subscribers: Arc>>>, echo_dispatch: Mutex>, diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs index 5fb004ad7..24236fad9 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs @@ -4,8 +4,9 @@ use std::io::{Read, Write}; use std::sync::atomic::Ordering; use std::sync::{mpsc, Arc, Mutex}; use std::thread; -use tauri::AppHandle; + +use crate::events::EventEmitter; use crate::runtime::project_path::{ project_path_key as normalize_project_path_key, project_path_keys_equal, }; @@ -13,9 +14,9 @@ use crate::runtime::project_path::{ use super::*; impl TerminalSessionRegistry { - pub fn attach_app_handle(&self, app_handle: AppHandle) { - if let Ok(mut slot) = self.app_handle.lock() { - *slot = Some(app_handle); + pub fn attach_event_emitter(&self, event_emitter: Arc) { + if let Ok(mut slot) = self.event_emitter.lock() { + *slot = Some(event_emitter); } } diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs index c131fed6c..b530eb358 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs @@ -50,8 +50,16 @@ pub(crate) fn terminate_process_tree_best_effort(pid: Option) { #[cfg(unix)] { + // Send SIGTERM to the process group identified by `-{pid}`. + // NOTE: the leading `--` argument separator is REQUIRED. Without it, + // procps-ng's `kill` mis-parses a large negative pid (e.g. -146676) + // as `kill(-1, SIGTERM)`, flooding SIGTERM to every process on the + // system and taking down the host server along with the terminal. + // The argument list is kept in a separate helper so tests can assert + // the `--` separator is preserved (a regression guard for the bug + // above). See `kill_sigterm_args` tests below. let _ = std::process::Command::new("kill") - .args(["-TERM", &format!("-{pid}")]) + .args(kill_sigterm_args(pid)) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -59,6 +67,19 @@ pub(crate) fn terminate_process_tree_best_effort(pid: Option) { } } +/// Build the argument vector sent to the system `kill`(1pg) for signalling the +/// process group whose leader has the given pid. +/// +/// The target is spelled as `-{pid}` so `kill` signals an entire process group +/// (`PGID`). **The trailing `--` argument separator MUST be present**: procps-ng +/// mis-parses a large negative pid (e.g. `-146676`) without it into +/// `kill(-1, SIGTERM)`, which floods SIGTERM to every process the caller may +/// signal — including the headless host server that spawned the terminal. +#[cfg(unix)] +pub(crate) fn kill_sigterm_args(pid: u32) -> Vec { + vec!["-TERM".to_string(), "--".to_string(), format!("-{pid}")] +} + pub(crate) fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs index 57c2fbacb..878f1d6f3 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs @@ -19,7 +19,7 @@ pub(crate) async fn run_ssh_session_io( let (mut read_half, write_half) = channel.split(); let (writer_end_tx, mut writer_end_rx) = tokio::sync::mpsc::channel::(1); let writer_runtime = Arc::clone(&runtime); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let mut writer = write_half.make_writer(); let reason = loop { tokio::select! { @@ -169,7 +169,7 @@ pub(crate) fn spawn_ssh_reconnect_runner( ) { // russh drives each session on the current Tokio runtime, so reconnects must // live on Tauri's long-running runtime rather than a short-lived thread runtime. - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { registry .handle_ssh_unexpected_disconnect(session_id, runtime, connection_id) .await; diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_local_forward.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_local_forward.rs index 41fb20b5c..dd35c931f 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_local_forward.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_local_forward.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::net::Ipv4Addr; use std::sync::{Arc, Mutex, Weak}; -use tauri::Emitter; + use tokio::io::{copy_bidirectional, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{watch, Semaphore}; @@ -10,6 +10,7 @@ use tokio::task::JoinSet; use tokio::time::timeout; use crate::runtime::project_path::project_path_keys_equal; +use crate::events::EventEmitterExt; use super::*; @@ -31,7 +32,7 @@ struct SshLocalForwardState { struct SshLocalForwardEntry { record: SshLocalForwardRecord, cancel_tx: watch::Sender, - task: Mutex>>, + task: Mutex>>, } impl Default for SshLocalForwardRegistry { @@ -196,7 +197,7 @@ impl TerminalSessionRegistry { let weak_registry = Arc::downgrade(self); let global_connections = Arc::clone(&self.ssh_local_forwards.global_connections); let forward_connections = Arc::new(Semaphore::new(SSH_LOCAL_FORWARD_MAX_CONNECTIONS)); - let task = tauri::async_runtime::spawn(run_ssh_local_forward_listener( + let task = crate::compat::async_runtime::spawn(run_ssh_local_forward_listener( weak_registry, forward_id.clone(), listener, @@ -379,9 +380,9 @@ impl TerminalSessionRegistry { // The desktop webview listens on the dedicated channel; gateway // subscribers get a terminal event so the WebUI shares the stream // without a second event pipeline. - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(SSH_LOCAL_FORWARD_EVENT_NAME, payload.clone()); + if let Ok(event_emitter) = self.event_emitter.lock() { + if let Some(event_emitter) = event_emitter.as_ref() { + let _ = event_emitter.emit(SSH_LOCAL_FORWARD_EVENT_NAME, payload.clone()); } } let subscribers = self diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs index 0c6941db4..497d960fa 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs @@ -249,7 +249,7 @@ impl TerminalSessionRegistry { self.broadcast("created", &entry, None, None, None); let registry = Arc::clone(self); - tauri::async_runtime::spawn(run_ssh_session_io( + crate::compat::async_runtime::spawn(run_ssh_session_io( registry, id.clone(), Arc::clone(&runtime), @@ -366,7 +366,7 @@ impl TerminalSessionRegistry { self.broadcast("reconnected", &entry, None, None, None); let registry = Arc::clone(self); - tauri::async_runtime::spawn(run_ssh_session_io( + crate::compat::async_runtime::spawn(run_ssh_session_io( registry, record.id, Arc::clone(runtime), diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs index 9fe7e75a6..b53338920 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs @@ -1096,3 +1096,229 @@ fn private_key_decode_error_explains_missing_passphrase() { "wrong-passphrase message should hint at the passphrase: {message}" ); } + +#[cfg(unix)] +#[test] +fn kill_sigterm_args_always_include_argument_separator() { + // Regression guard for the SIGTERM broadcast bug: procps-ng mis-parses a + // large negative pid (e.g. -146676) without a trailing `--` into + // `kill(-1, SIGTERM)`, flooding SIGTERM to every process on the system and + // taking down the headless host along with the terminal being closed. + for pid in [1u32, 42u32, 6000u32, 146_676u32, u32::MAX] { + let args = kill_sigterm_args(pid); + assert_eq!( + args, + vec!["-TERM".to_string(), "--".to_string(), format!("-{pid}")], + "kill args must signal the literal process group -{} and keep the `--` separator", + pid, + ); + // The group must be addressed as its literal negative pid; a large + // value must never be flattened to `-1` (which would mean kill all). + let group_token = &args[2]; + assert!(group_token.starts_with('-'), "group token must be negative"); + let parsed: i64 = group_token.parse().expect("negative pid parses"); + assert_eq!( + parsed, + -(pid as i64), + "kill group token must be exactly -{pid} (no collapse to -1)" + ); + } +} + +#[cfg(unix)] +#[test] +fn terminate_process_tree_kills_only_target_group() { + use std::process::{Command, Stdio}; + use std::thread::sleep; + use std::time::{Duration, Instant}; + + /// Process state char from /proc//stat, or None when the process is + /// gone. Zombies (Z/X) count as terminated — the bug we guard against is + /// processes that stay *runnable* (S/R) after the group kill. + fn proc_state(pid: u32) -> Option { + let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let body = raw.split(')').last()?.trim_start(); + body.chars().next() + } + + fn terminated(pid: u32) -> bool { + matches!(proc_state(pid), None | Some('Z') | Some('X')) + } + + // A "host" sentinel in a separate, unrelated process group. The bug's + // regression signature is SIGTERM leaking beyond the target group onto + // processes like this one (and the test runner itself); it must survive. + let mut host_guard = match Command::new("/bin/sh") + .arg("-c") + .arg("sleep 30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(_) => return, + }; + let host_pid = host_guard.id(); + + // Spawn a detached session in its OWN process group (PGID == leader pid). + // The leader demotes itself off the test's process group and publishes its + // new group's leader pid, then holds two (`sleep 30`) children in-group. + // NOTE: the TempDir must stay alive for the whole test — dropping it would + // delete the pid file path the spawned session writes into. + let pid_dir = tempfile::tempdir().expect("tempdir"); + let pid_file_path = pid_dir + .path() + .join("leader.pid") + .to_string_lossy() + .to_string(); + let group_script = + format!("setsid sh -c 'echo $$ > {pid_file_path}; (sleep 30) & sleep 30'"); + + let Ok(mut target_child) = Command::new("/bin/sh") + .arg("-c") + .arg(&group_script) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + let _ = host_guard.kill(); + return; // setsid unavailable on this host; skip the live-process portion + }; + + let deadline = Instant::now() + Duration::from_secs(5); + let group_leader = loop { + if let Ok(contents) = std::fs::read_to_string(&pid_file_path) { + if let Ok(pid) = contents.trim().parse::() { + if pid != 0 { + break pid; + } + } + } + if Instant::now() > deadline { + let _ = target_child.kill(); + let _ = host_guard.kill(); + panic!("group leader did not publish its pid within 5s"); + } + sleep(Duration::from_millis(20)); + }; + + // Collect group members via /proc (PGID == group_leader) so we can assert + // on every process the kill must terminate. + let mut members = vec![group_leader]; + for entry in std::fs::read_dir("/proc").expect("read /proc") { + let name = entry + .expect("proc entry") + .file_name() + .to_string_lossy() + .to_string(); + let Ok(pid) = name.parse::() else { + continue; + }; + if pid == group_leader { + continue; + } + let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap_or_default(); + if let Some(rest) = raw.split(')').last() { + let parts: Vec<&str> = rest.trim_start().split_whitespace().collect(); + if parts.len() > 2 && parts[2].parse::().ok() == Some(group_leader) { + members.push(pid); + } + } + } + // Sanity: the fully populated group is alive before we kill it. + sleep(Duration::from_millis(200)); + assert!( + members.iter().all(|pid| !terminated(*pid)), + "target group should be alive before kill (members={members:?})" + ); + + // Kill ONLY the target process group (PGID == group_leader). + terminate_process_tree_best_effort(Some(group_leader)); + + // Every member of the target group must be terminated shortly after. + let wait_until = Instant::now() + Duration::from_secs(5); + loop { + if members.iter().all(|pid| terminated(*pid)) { + break; + } + assert!( + Instant::now() < wait_until, + "target process group was not terminated within 5s: {members:?}" + ); + sleep(Duration::from_millis(20)); + } + + // The unrelated "host" guard process must survive untouched. + let host_alive = !terminated(host_pid); + let _ = target_child.kill(); + let _ = host_guard.kill(); + assert!( + host_alive, + "host process must be left untouched by group kill" + ); +} + +#[cfg(unix)] +#[test] +fn registry_local_session_close_reaps_process_group() { + use std::time::Duration; + + // End-to-end: a real local terminal is spawned and commands spawn children + // into the same process group; closing the session must reap the group + // without killing the current process (the "host"). + let registry = Arc::new(TerminalSessionRegistry::default()); + let tempdir = tempfile::tempdir().expect("tempdir"); + let cwd = tempdir.path().display().to_string(); + + let session = registry + .create( + cwd.clone(), + Some(cwd.clone()), + None, + Some("GroupReap".to_string()), + Some(80), + Some(24), + ) + .expect("create terminal session"); + let session_id = session.session.id.clone(); + let pid = session.session.pid.expect("local session has a leader pid"); + + let host_before = std::process::id(); + + registry.close(session_id).expect("close terminal session"); + + let reap_ok = (0..200).any(|_| { + std::thread::sleep(Duration::from_millis(20)); + proc_is_terminated(pid) + }); + assert!( + reap_ok, + "local terminal session process group should be reaped after close" + ); + assert_eq!( + host_before, + std::process::id(), + "closing a local terminal must not terminate the host process" + ); +} + +#[cfg(unix)] +fn proc_is_terminated(pid: u32) -> bool { + if pid == 0 { + return true; + } + let raw = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(raw) => raw, + Err(_) => return true, // /proc entry gone = terminated + }; + let Some(body) = raw.split(')').last() else { + return true; + }; + let Some(state) = body.trim_start().chars().next() else { + return true; + }; + // Zombies / dead states count as terminated. + matches!(state, 'Z' | 'X' | 'D' | 'T') +} diff --git a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs index fe64cd143..e858a8503 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs @@ -60,7 +60,7 @@ impl AutomationScheduler { } pub fn start(self: Arc) { - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { self.run_loop().await; }); } @@ -73,7 +73,7 @@ impl AutomationScheduler { async fn run_loop(self: Arc) { { let store = Arc::clone(&self.store); - let recovered = tauri::async_runtime::spawn_blocking(move || { + let recovered = crate::compat::async_runtime::spawn_blocking(move || { store.recover_interrupted_prompt_runs() }) .await; @@ -106,7 +106,7 @@ impl AutomationScheduler { } _ = sweep.tick() => { let store = Arc::clone(&self.store); - let result = tauri::async_runtime::spawn_blocking(move || { + let result = crate::compat::async_runtime::spawn_blocking(move || { store.sweep_expired_prompt_runs() }) .await; @@ -143,7 +143,7 @@ impl AutomationScheduler { self.ensure_scheduler().await?; let store = Arc::clone(&self.store); - let tasks = tauri::async_runtime::spawn_blocking(move || store.runnable_cron_tasks()) + let tasks = crate::compat::async_runtime::spawn_blocking(move || store.runnable_cron_tasks()) .await .map_err(|e| format!("automation reload join 失败:{e}"))??; @@ -238,8 +238,8 @@ impl AutomationScheduler { fn report_task_error(self: &Arc, task_id: &str, error: Option) { let store = Arc::clone(&self.store); let task_id = task_id.to_string(); - tauri::async_runtime::spawn(async move { - let result = tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn(async move { + let result = crate::compat::async_runtime::spawn_blocking(move || { store.set_task_error(&task_id, error.as_deref()) }) .await; @@ -255,7 +255,7 @@ impl AutomationScheduler { let fresh = { let store = Arc::clone(&self.store); let task_id = task_id.clone(); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { store.cron_task_for_scheduled_fire(&task_id) }) .await @@ -307,7 +307,7 @@ impl AutomationScheduler { } let manager = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { manager.execute_fire(task, workdir, trigger).await; }); true @@ -320,7 +320,7 @@ impl AutomationScheduler { let can_run = { let store = Arc::clone(&self.store); let task_id = task_id.clone(); - tauri::async_runtime::spawn_blocking(move || store.task_can_run(&task_id)).await + crate::compat::async_runtime::spawn_blocking(move || store.task_can_run(&task_id)).await }; match can_run { Ok(Ok(true)) => {} @@ -375,7 +375,7 @@ impl AutomationScheduler { let store = Arc::clone(&self.store); let queue_task = task.clone(); let queue_workdir = workdir.clone(); - let result = tauri::async_runtime::spawn_blocking(move || { + let result = crate::compat::async_runtime::spawn_blocking(move || { store.queue_prompt_run(&queue_task, &queue_workdir, trigger.counted()) }) .await; @@ -400,7 +400,7 @@ impl AutomationScheduler { return; } - let run = tauri::async_runtime::spawn_blocking(move || { + let run = crate::compat::async_runtime::spawn_blocking(move || { let mut run = execute_blocking(task, workdir); run.counted = trigger.counted(); run @@ -426,8 +426,8 @@ impl AutomationScheduler { fn disable_task_detached(&self, task_id: &str, error: String) { let store = Arc::clone(&self.store); let task_id = task_id.to_string(); - tauri::async_runtime::spawn(async move { - let result = tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn(async move { + let result = crate::compat::async_runtime::spawn_blocking(move || { store.disable_task_with_error(&task_id, &error) }) .await; @@ -441,9 +441,9 @@ impl AutomationScheduler { fn record_run_detached(&self, run: CompletedRun) { let store = Arc::clone(&self.store); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = - tauri::async_runtime::spawn_blocking(move || store.record_completed_run(run)).await; + crate::compat::async_runtime::spawn_blocking(move || store.record_completed_run(run)).await; match result { Ok(Err(error)) => eprintln!("Cron run 记录失败:{error}"), Err(error) => eprintln!("Cron run 记录 join 失败:{error}"), diff --git a/crates/agent-gui/src-tauri/src/services/automation/store.rs b/crates/agent-gui/src-tauri/src/services/automation/store.rs index 6eac95d5b..90e42e573 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/store.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/store.rs @@ -1,10 +1,11 @@ -use std::sync::{Mutex, Weak}; +use std::sync::{Arc, Mutex, Weak}; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde_json::Value; -use tauri::Emitter; use uuid::Uuid; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; use crate::services::gateway::GatewayController; use super::db; @@ -16,14 +17,14 @@ use super::validate; /// here so every writer (UI apply, LLM tool, gateway relay, executor /// decrement) produces exactly the same broadcast. pub struct AutomationNotifier { - pub app_handle: tauri::AppHandle, + pub event_emitter: Arc, pub gateway: Weak, pub scheduler: Weak, } impl AutomationNotifier { fn cron_changed(&self, snapshot: &CronSnapshot) { - if let Err(error) = self.app_handle.emit(CRON_CHANGED_EVENT, snapshot) { + if let Err(error) = self.event_emitter.emit(CRON_CHANGED_EVENT, snapshot) { eprintln!("emit {CRON_CHANGED_EVENT} failed: {error}"); } if let Some(scheduler) = self.scheduler.upgrade() { @@ -33,20 +34,20 @@ impl AutomationNotifier { } fn hooks_changed(&self, snapshot: &HooksSnapshot) { - if let Err(error) = self.app_handle.emit(HOOKS_CHANGED_EVENT, snapshot) { + if let Err(error) = self.event_emitter.emit(HOOKS_CHANGED_EVENT, snapshot) { eprintln!("emit {HOOKS_CHANGED_EVENT} failed: {error}"); } self.refresh_gateway(); } fn prompt_pending(&self) { - if let Err(error) = self.app_handle.emit(PROMPT_PENDING_EVENT, ()) { + if let Err(error) = self.event_emitter.emit(PROMPT_PENDING_EVENT, ()) { eprintln!("emit {PROMPT_PENDING_EVENT} failed: {error}"); } } fn prompt_expired(&self, event: &PromptExpiredEvent) { - if let Err(error) = self.app_handle.emit(PROMPT_EXPIRED_EVENT, event) { + if let Err(error) = self.event_emitter.emit(PROMPT_EXPIRED_EVENT, event) { eprintln!("emit {PROMPT_EXPIRED_EVENT} failed: {error}"); } } @@ -55,7 +56,7 @@ impl AutomationNotifier { let Some(gateway) = self.gateway.upgrade() else { return; }; - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { if let Err(error) = gateway.refresh_settings_sync_from_db().await { eprintln!("refresh gateway settings sync after automation change failed: {error}"); } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs index ae538d1ee..fdb76462a 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use std::time::Duration; use serde_json::json; -use tauri::Emitter; use tokio::sync::oneshot; use uuid::Uuid; +use crate::events::EventEmitterExt; use crate::services::chat_run_ledger::ChatRunLedgerEntry; use super::*; @@ -101,7 +101,7 @@ impl GatewayController { "cancelled", ) .await?; - self.app_handle + self.event_emitter .emit( "gateway:chat-cancel", GatewayChatCancelEvent { @@ -146,7 +146,7 @@ impl GatewayController { return Err(error); } if enqueue_outcome.should_wake_runtime { - self.app_handle + self.event_emitter .emit( "gateway:chat-request-ready", json!({ "requestId": enqueue_outcome.request_id }), @@ -276,7 +276,7 @@ impl GatewayController { .insert(request_id.clone(), tx); if let Err(error) = self - .app_handle + .event_emitter .emit("gateway:chat-queue-request", event_payload) { let _ = self diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs index 6db0681a7..a94c32f93 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs @@ -1,8 +1,8 @@ use std::time::{Duration, Instant}; use serde_json::json; -use tauri::Emitter; +use crate::events::EventEmitterExt; use crate::services::chat_run_ledger::{ChatRunLedger, ChatRunLedgerState}; use super::*; @@ -793,7 +793,7 @@ impl GatewayController { } } if wake { - let _ = self.app_handle.emit( + let _ = self.event_emitter.emit( "gateway:chat-request-ready", json!({ "reason": "lease_expired" }), ); diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress.rs index 4a1814216..177481d4a 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress.rs @@ -9,10 +9,11 @@ use std::time::Duration; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tauri::Emitter; use tokio::sync::oneshot; use super::{now_unix_seconds, GATEWAY_CHAT_CHECKPOINT_REQUESTED_EVENT}; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; const CHAT_INGRESS_DB_FILENAME: &str = "gateway-chat-sync.sqlite3"; const CHAT_INGRESS_DB_SCHEMA_VERSION: i64 = 2; @@ -209,13 +210,13 @@ enum ChatIngressCommand { } impl ChatIngressMirror { - pub(crate) fn spawn(app_handle: tauri::AppHandle) -> Self { + pub(crate) fn spawn(event_emitter: Arc) -> Self { let (tx, rx) = std_mpsc::sync_channel(CHAT_INGRESS_ACTOR_QUEUE_COMMANDS); let queued_bytes = Arc::new(AtomicUsize::new(0)); let actor_queued_bytes = Arc::clone(&queued_bytes); thread::Builder::new() .name("gateway-chat-ingress".to_string()) - .spawn(move || run_actor(app_handle, rx, actor_queued_bytes)) + .spawn(move || run_actor(event_emitter, rx, actor_queued_bytes)) .expect("spawn gateway chat ingress actor"); Self { tx, queued_bytes } } @@ -381,7 +382,7 @@ struct ChatIngressActor { journal: Result, rings: HashMap<(String, String), RunDeltaRing>, global_ring_bytes: usize, - app_handle: Option, + event_emitter: Option>, } #[derive(Default)] @@ -398,7 +399,7 @@ struct RingDelta { } fn run_actor( - app_handle: tauri::AppHandle, + event_emitter: Arc, rx: std_mpsc::Receiver, queued_bytes: Arc, ) { @@ -407,7 +408,7 @@ fn run_actor( journal, rings: HashMap::new(), global_ring_bytes: 0, - app_handle: Some(app_handle), + event_emitter: Some(event_emitter), }; while let Ok(queued) = rx.recv() { actor.handle(queued.command); @@ -863,8 +864,8 @@ impl ChatIngressActor { delta_bytes: ring.map(|ring| ring.bytes).unwrap_or(0), journal_bytes, }; - if let Some(app_handle) = &self.app_handle { - if let Err(error) = app_handle.emit(GATEWAY_CHAT_CHECKPOINT_REQUESTED_EVENT, event) { + if let Some(event_emitter) = &self.event_emitter { + if let Err(error) = event_emitter.emit(GATEWAY_CHAT_CHECKPOINT_REQUESTED_EVENT, event) { eprintln!("emit gateway chat checkpoint request failed: {error}"); } } @@ -1851,7 +1852,7 @@ mod tests { journal: Ok(journal), rings: HashMap::new(), global_ring_bytes: 0, - app_handle: None, + event_emitter: None, }, ) } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress_transport.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress_transport.rs index 61ea39f8c..81fac510a 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress_transport.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat_ingress_transport.rs @@ -94,7 +94,7 @@ impl GatewayController { return; } let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { if let Err(error) = controller.flush_chat_ingress().await { eprintln!("flush gateway chat ingress failed: {error}"); } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs index 429b9dce9..d89998dcf 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs @@ -5,10 +5,10 @@ use std::time::{Duration, Instant}; use futures_util::{SinkExt as _, StreamExt as _}; use prost::Message as _; use serde_json::Value; -use tauri::Emitter; use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, Semaphore}; use tokio_tungstenite::tungstenite::Message as WsMessage; +use crate::events::EventEmitterExt; use crate::commands::settings::RemoteSettingsPayload; use crate::runtime::terminal::TerminalEventPayload; use crate::services::gateway_bridge; @@ -39,7 +39,7 @@ const GATEWAY_WRITE_TIMEOUT_MIN: Duration = Duration::from_secs(10); const GATEWAY_WRITE_TIMEOUT_MAX: Duration = Duration::from_secs(60); /// 后台任务句柄的 RAII 中止器。 -struct AbortTaskOnDrop(tauri::async_runtime::JoinHandle<()>); +struct AbortTaskOnDrop(crate::compat::async_runtime::JoinHandle<()>); impl Drop for AbortTaskOnDrop { fn drop(&mut self) { @@ -183,7 +183,7 @@ impl GatewayOutboundSender { } pub(crate) fn blocking_send(&self, envelope: proto::AgentEnvelope) -> Result<(), String> { - tauri::async_runtime::block_on(self.send(envelope)) + crate::compat::async_runtime::block_on(self.send(envelope)) } } @@ -439,7 +439,7 @@ impl GatewayController { let (last_inbound_tx, last_inbound_rx) = watch::channel(Instant::now()); let writer_failure_tx = failure_tx.clone(); - let writer_task = tauri::async_runtime::spawn(async move { + let writer_task = crate::compat::async_runtime::spawn(async move { if let Err(error) = run_gateway_writer( ws_sink, system_write_rx, @@ -455,7 +455,7 @@ impl GatewayController { let dispatcher = Arc::clone(self); let dispatcher_failure_tx = failure_tx.clone(); - let dispatcher_task = tauri::async_runtime::spawn(async move { + let dispatcher_task = crate::compat::async_runtime::spawn(async move { while let Some(envelope) = dispatch_rx.recv().await { if let Err(error) = dispatcher.handle_gateway_envelope(envelope).await { let _ = dispatcher_failure_tx @@ -467,7 +467,7 @@ impl GatewayController { let watchdog_write_tx = system_write_tx.clone(); let watchdog_failure_tx = failure_tx.clone(); - let watchdog_task = tauri::async_runtime::spawn(async move { + let watchdog_task = crate::compat::async_runtime::spawn(async move { run_gateway_watchdog( last_inbound_rx, watchdog_write_tx, @@ -558,9 +558,9 @@ impl GatewayController { pub(crate) fn spawn_post_connect_reconciliation( self: &Arc, - ) -> tauri::async_runtime::JoinHandle<()> { + ) -> crate::compat::async_runtime::JoinHandle<()> { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { // Runtime readiness is control-plane state: restore it immediately // on the fresh stream before low-priority snapshots begin replaying. if let Some((worker_id, state, visible, active_run_count)) = @@ -655,7 +655,7 @@ impl GatewayController { request: proto::UploadedImagePreviewRequest, ) -> Result<(), String> { let sender = self.current_outbound_sender()?; - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let envelope = match gateway_bridge::handle_uploaded_image_preview(request).await { Ok(response) => proto::AgentEnvelope { request_id, @@ -745,7 +745,7 @@ impl GatewayController { } else { return; }; - let _ = self.app_handle.emit("gateway:status", next); + let _ = self.event_emitter.emit("gateway:status", next); } pub(crate) async fn publish_current_settings_sync(&self) -> Result<(), String> { @@ -775,7 +775,7 @@ impl GatewayController { pub async fn refresh_settings_sync_from_db(&self) -> Result { let snapshot = self.current_settings_snapshot().await?; - self.app_handle + self.event_emitter .emit(GATEWAY_SETTINGS_SYNC_EVENT, snapshot.clone()) .map_err(|e| format!("emit gateway settings sync failed: {e}"))?; self.publish_settings_sync(snapshot.clone()).await?; diff --git a/crates/agent-gui/src-tauri/src/services/gateway/controller.rs b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs index 23507b243..530bd5bcf 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/controller.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs @@ -3,10 +3,11 @@ use std::sync::{Arc, Mutex, Once}; use std::thread; use serde_json::{json, Value}; -use tauri::Emitter; use tokio::sync::watch; use crate::commands::git::GitCloneTaskRegistry; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; use crate::commands::settings::{ load_remote_settings, normalize_remote_settings_payload, open_db, RemoteSettingsPayload, }; @@ -24,7 +25,7 @@ use super::*; impl GatewayController { pub fn new( - app_handle: tauri::AppHandle, + event_emitter: Arc, automation_store: Arc, memory_store: Arc, provider_usage_service: Arc, @@ -35,11 +36,11 @@ impl GatewayController { ) -> Self { let initial_config = RemoteSettingsPayload::default(); let (config_tx, _) = watch::channel(initial_config); - let tunnel_store = TunnelStore::new(app_handle.clone()); - let workspace_watch = Arc::new(WorkspaceWatchService::new(app_handle.clone())); - let chat_ingress = ChatIngressMirror::spawn(app_handle.clone()); + let tunnel_store = TunnelStore::new(Arc::clone(&event_emitter)); + let workspace_watch = Arc::new(WorkspaceWatchService::new(Arc::clone(&event_emitter))); + let chat_ingress = ChatIngressMirror::spawn(Arc::clone(&event_emitter)); Self { - app_handle, + event_emitter, automation_store, memory_store, provider_usage_service, @@ -155,7 +156,7 @@ impl GatewayController { pub(crate) fn start_remote_chat_inbox_sweeper(self: &Arc) { let controller = Arc::clone(self); self.remote_chat_inbox_sweeper_once.call_once(move || { - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { loop { tokio::time::sleep(GATEWAY_CHAT_LEASE_SWEEP_INTERVAL).await; if let Err(error) = controller.expire_remote_chat_leases().await { @@ -175,7 +176,7 @@ impl GatewayController { pub(crate) fn start_runtime_status_republisher(self: &Arc) { let controller = Arc::clone(self); self.runtime_status_republisher_once.call_once(move || { - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { loop { tokio::time::sleep(GATEWAY_RUNTIME_STATUS_REPUBLISH_INTERVAL).await; let Some((worker_id, state, visible, active_run_count)) = @@ -201,11 +202,11 @@ impl GatewayController { pub(crate) fn spawn_runner( self: &Arc, - runner_task: &mut Option>, + runner_task: &mut Option>, ) { let receiver = self.config_tx.subscribe(); let controller = Arc::clone(self); - *runner_task = Some(tauri::async_runtime::spawn(async move { + *runner_task = Some(crate::compat::async_runtime::spawn(async move { controller.run(receiver).await; })); } @@ -217,7 +218,7 @@ impl GatewayController { .map_err(|_| "gateway runner task lock poisoned".to_string())?; let should_spawn = runner_task .as_ref() - .map(|task| task.inner().is_finished()) + .map(|task| task.is_finished()) .unwrap_or(true); if !should_spawn { return Ok(()); @@ -243,7 +244,7 @@ impl GatewayController { } pub fn wake_chat_runtime(&self, reason: &str) -> Result<(), String> { - self.app_handle + self.event_emitter .emit( GATEWAY_CHAT_RUNTIME_WAKE_EVENT, json!({ "reason": reason.trim() }), @@ -294,7 +295,7 @@ impl GatewayController { } pub async fn reload_from_db(self: &Arc) -> Result<(), String> { - let config = tauri::async_runtime::spawn_blocking(move || { + let config = crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; load_remote_settings(&conn) }) @@ -353,7 +354,7 @@ impl GatewayController { } pub async fn publish_history_sync(&self, event: GatewayHistorySyncEvent) { - if let Err(error) = self.app_handle.emit(CHAT_HISTORY_SYNC_EVENT, event.clone()) { + if let Err(error) = self.event_emitter.emit(CHAT_HISTORY_SYNC_EVENT, event.clone()) { eprintln!("emit chat history sync failed: {error}"); } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs index e37b6afff..93ee8c7ad 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use serde_json::Value; -use tauri::Emitter; +use crate::events::EventEmitterExt; use crate::commands::chat_history::{self}; use crate::commands::settings::{ apply_ssh_patch_with_conn, open_db, redact_gateway_settings_sync_payload, @@ -110,7 +110,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryList(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_list(request).await { Ok(response) => { controller @@ -137,7 +137,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryWorkdirs(_request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_workdirs().await { Ok(response) => { controller @@ -166,7 +166,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryGet(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_get(request).await { Ok(response) => { controller @@ -193,7 +193,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryPrefix(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_prefix(request).await { Ok(response) => { controller @@ -220,7 +220,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryRename(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_rename(request).await { Ok(response) => { if let Some(conversation) = response.conversation.as_ref() { @@ -254,7 +254,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryBranch(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_branch(request).await { Ok(response) => { if let Some(conversation) = response.conversation.as_ref() { @@ -288,7 +288,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryPin(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_pin(request).await { Ok(response) => { if let Some(conversation) = response.conversation.as_ref() { @@ -322,7 +322,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryShareGet(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_share_get(request).await { Ok(response) => { controller @@ -351,7 +351,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryShareSet(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_share_set(request).await { Ok(response) => { if let Some(share) = response.share.as_ref() { @@ -400,7 +400,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::HistoryShareResolve(request)) => { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_share_resolve(request).await { Ok(response) => { controller @@ -431,7 +431,7 @@ impl GatewayController { Some(proto::gateway_envelope::Payload::HistoryDelete(request)) => { let deleted_conversation_id = request.conversation_id.trim().to_string(); let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match gateway_bridge::handle_history_delete(request).await { Ok(response) => { controller @@ -479,7 +479,7 @@ impl GatewayController { Some(proto::gateway_envelope::Payload::ProviderUsage(request)) => { let sender = self.current_outbound_sender()?; let provider_usage_service = Arc::clone(&self.provider_usage_service); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let envelope = match gateway_bridge::handle_provider_usage( provider_usage_service, request, @@ -537,7 +537,7 @@ impl GatewayController { if snapshot.get(SSH_PATCH_FIELD).is_some() { let patch_payload = snapshot.clone(); let apply_response = - match tauri::async_runtime::spawn_blocking(move || { + match crate::compat::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; apply_ssh_patch_with_conn(&mut conn, patch_payload) }) @@ -589,7 +589,7 @@ impl GatewayController { } }; if let Err(error) = self - .app_handle + .event_emitter .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) { return self @@ -655,7 +655,7 @@ impl GatewayController { return self.send_error_response(request_id, 500, error).await; } match self - .app_handle + .event_emitter .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) { Ok(()) => { @@ -803,7 +803,7 @@ impl GatewayController { } Some(proto::gateway_envelope::Payload::ChatFileOpen(request)) => { let sender = self.current_outbound_sender()?; - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let envelope = match gateway_bridge::handle_chat_file_open(request).await { Ok(response) => proto::AgentEnvelope { request_id, @@ -1036,7 +1036,7 @@ impl GatewayController { // A stop carries a bounded TERM grace; run it off the inbound // stream loop so tunnel frames and pings keep flowing. let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let result = match controller.handle_managed_process_request(request).await { Ok(response) => { controller diff --git a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs index dfedceace..138f05c68 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs @@ -20,6 +20,7 @@ use serde_json::Value; use tokio::sync::{mpsc, oneshot, watch}; use crate::commands::git::GitCloneTaskRegistry; +use crate::events::EventEmitter; use crate::commands::settings::RemoteSettingsPayload; use crate::runtime::managed_process::ManagedProcessRegistry; use crate::runtime::sftp::SftpSessionRegistry; @@ -44,7 +45,7 @@ pub use gateway_proto::v2 as proto; mod chat; mod chat_inbox; -mod chat_ingress; +pub(crate) mod chat_ingress; mod chat_ingress_transport; mod connection; mod controller; @@ -119,7 +120,7 @@ pub(crate) const GATEWAY_CHAT_CHECKPOINT_REQUESTED_EVENT: &str = "gateway:chat-checkpoint-requested"; pub struct GatewayController { - app_handle: tauri::AppHandle, + event_emitter: Arc, automation_store: Arc, memory_store: Arc, provider_usage_service: Arc, @@ -128,7 +129,7 @@ pub struct GatewayController { managed_process_registry: Arc, pub(crate) git_clone_task_registry: Arc, config_tx: watch::Sender, - runner_task: Mutex>>, + runner_task: Mutex>>, status: Mutex, outbound_tx: Mutex>, outbound_control_tx: Mutex>, diff --git a/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs b/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs index 9906c2c06..f55b45e12 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs @@ -17,7 +17,7 @@ impl GatewayController { .map_err(|_| "gateway settings snapshot lock poisoned".to_string())? .clone(); - let db_snapshot = tauri::async_runtime::spawn_blocking(move || { + let db_snapshot = crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; load_gateway_settings_sync_snapshot(&conn) }) diff --git a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs index 8f34e40bc..659728286 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs @@ -30,9 +30,9 @@ impl GatewayController { self: &Arc, config: RemoteSettingsPayload, stop_rx: watch::Receiver, - ) -> tauri::async_runtime::JoinHandle<()> { + ) -> crate::compat::async_runtime::JoinHandle<()> { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { controller.run_terminal_stream_ws(config, stop_rx).await; }) } diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index 3b2707fb7..f5cdf9d01 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -77,7 +77,7 @@ pub async fn handle_cron_manage( let result_json = match action.as_str() { "snapshot" => { let store = Arc::clone(&store); - let snapshot = tauri::async_runtime::spawn_blocking(move || store.snapshot()) + let snapshot = crate::compat::async_runtime::spawn_blocking(move || store.snapshot()) .await .map_err(|e| format!("gateway automation snapshot join failed: {e}"))??; serialize_cron_manage_result(&snapshot)? @@ -85,7 +85,7 @@ pub async fn handle_cron_manage( "cron_apply" => { let input = parse_apply_input(&request.task_json)?; let store = Arc::clone(&store); - let response = tauri::async_runtime::spawn_blocking(move || store.cron_apply(input)) + let response = crate::compat::async_runtime::spawn_blocking(move || store.cron_apply(input)) .await .map_err(|e| format!("gateway cron apply join failed: {e}"))??; serialize_cron_manage_result(&response)? @@ -93,7 +93,7 @@ pub async fn handle_cron_manage( "hooks_apply" => { let input = parse_apply_input(&request.task_json)?; let store = Arc::clone(&store); - let response = tauri::async_runtime::spawn_blocking(move || store.hooks_apply(input)) + let response = crate::compat::async_runtime::spawn_blocking(move || store.hooks_apply(input)) .await .map_err(|e| format!("gateway hooks apply join failed: {e}"))??; serialize_cron_manage_result(&response)? @@ -103,7 +103,7 @@ pub async fn handle_cron_manage( let limit = parse_runs_limit(&request.task_json)?; let store = Arc::clone(&store); let runs = - tauri::async_runtime::spawn_blocking(move || store.list_runs(&task_id, limit)) + crate::compat::async_runtime::spawn_blocking(move || store.list_runs(&task_id, limit)) .await .map_err(|e| format!("gateway list_runs join failed: {e}"))??; serialize_cron_manage_result(&json!({ "runs": runs }))? @@ -111,7 +111,7 @@ pub async fn handle_cron_manage( "clear_runs" => { let task_id = parse_required_cron_task_id(&request, "clear_runs")?; let store = Arc::clone(&store); - let cleared = tauri::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) + let cleared = crate::compat::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) .await .map_err(|e| format!("gateway clear_runs join failed: {e}"))??; serialize_cron_manage_result(&json!({ "clearedCount": cleared }))? @@ -120,14 +120,14 @@ pub async fn handle_cron_manage( let task_id = parse_required_cron_task_id(&request, "run_now")?; let store = Arc::clone(&store); let response = - tauri::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) + crate::compat::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) .await .map_err(|e| format!("gateway run_now join failed: {e}"))??; serialize_cron_manage_result(&response)? } "validate" => { let expression = parse_validate_expression(&request.task_json)?; - tauri::async_runtime::spawn_blocking(move || validate_cron_expression(&expression)) + crate::compat::async_runtime::spawn_blocking(move || validate_cron_expression(&expression)) .await .map_err(|e| format!("gateway cron validate join failed: {e}"))??; serialize_cron_manage_result(&json!({ "valid": true }))? @@ -357,7 +357,7 @@ pub async fn handle_history_delete( } pub async fn handle_provider_list() -> Result { - let providers = tauri::async_runtime::spawn_blocking(move || { + let providers = crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; load_providers(&conn) }) @@ -384,7 +384,7 @@ pub async fn handle_provider_models( } pub async fn handle_skill_files_list() -> Result { - tauri::async_runtime::spawn_blocking(system_list_skill_files_sync) + crate::compat::async_runtime::spawn_blocking(system_list_skill_files_sync) .await .map_err(|e| format!("gateway skill files list join failed: {e}"))? .map(|response| proto::SkillFilesListResponse { @@ -401,7 +401,7 @@ pub async fn handle_file_mention_list( .ok() .filter(|value| *value > 0); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_mention_list_sync( request.workdir, max_results, @@ -426,7 +426,7 @@ pub async fn handle_file_mention_list( } pub async fn handle_fs_roots() -> Result { - tauri::async_runtime::spawn_blocking(fs_roots_sync) + crate::compat::async_runtime::spawn_blocking(fs_roots_sync) .await .map_err(|e| format!("gateway fs roots join failed: {e}"))? .map(|response| proto::FsRootsResponse { @@ -446,7 +446,7 @@ pub async fn handle_fs_roots() -> Result { pub async fn handle_fs_list_dirs( request: proto::FsListDirsRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let max_results = usize::try_from(request.max_results) .ok() .filter(|value| *value > 0); @@ -471,7 +471,7 @@ pub async fn handle_fs_list_dirs( pub async fn handle_fs_create_project_folder( request: proto::FsCreateProjectFolderRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_create_project_folder_sync(request.parent, request.name) }) .await @@ -497,7 +497,7 @@ pub async fn handle_fs_list( .ok() .filter(|value| *value > 0); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_list_sync( request.workdir, path, @@ -536,7 +536,7 @@ pub async fn handle_fs_list( pub async fn handle_fs_read_editable_text( request: proto::FsReadEditableTextRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_read_editable_text_sync(request.workdir, request.path) }) .await @@ -555,7 +555,7 @@ pub async fn handle_fs_read_editable_text( pub async fn handle_fs_read_workspace_image( request: proto::FsReadWorkspaceImageRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_read_workspace_image_sync(request.workdir, request.path) }) .await @@ -618,7 +618,7 @@ pub async fn handle_fs_write_text( None }; - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_write_text_sync( request.workdir, request.path, @@ -645,7 +645,7 @@ pub async fn handle_fs_write_text( pub async fn handle_fs_create_dir( request: proto::FsCreateDirRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || fs_create_dir_sync(request.workdir, request.path)) + crate::compat::async_runtime::spawn_blocking(move || fs_create_dir_sync(request.workdir, request.path)) .await .map_err(|e| format!("gateway fs create dir join failed: {e}"))? .map_err(|e| e.message) @@ -658,7 +658,7 @@ pub async fn handle_fs_create_dir( pub async fn handle_fs_rename( request: proto::FsRenameRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { fs_rename_sync(request.workdir, request.from_path, request.to_path) }) .await @@ -674,7 +674,7 @@ pub async fn handle_fs_rename( pub async fn handle_fs_delete( request: proto::FsDeleteRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || fs_delete_sync(request.workdir, request.path)) + crate::compat::async_runtime::spawn_blocking(move || fs_delete_sync(request.workdir, request.path)) .await .map_err(|e| format!("gateway fs delete join failed: {e}"))? .map_err(|e| e.message) @@ -689,7 +689,7 @@ pub async fn handle_git_request( clone_task_registry: Arc, ) -> Result { let action = request.action.trim().to_string(); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let result = git_gateway_clone_task_action_sync( action.clone(), request.workdir, @@ -723,7 +723,7 @@ pub async fn handle_upload_readable_files( }) .collect(); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_import_uploaded_readable_files_sync(workdir, uploads) }) .await @@ -747,7 +747,7 @@ pub async fn handle_upload_readable_files( pub async fn handle_uploaded_image_preview( request: proto::UploadedImagePreviewRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_read_uploaded_image_preview_sync(request.workdir, request.absolute_path) }) .await @@ -762,7 +762,7 @@ pub async fn handle_memory_manage( memory_store: Arc, request: proto::MemoryManageRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || handle_memory_manage_sync(memory_store, request)) + crate::compat::async_runtime::spawn_blocking(move || handle_memory_manage_sync(memory_store, request)) .await .map_err(|e| format!("gateway memory manage join failed: {e}"))? } @@ -925,7 +925,7 @@ fn parse_memory_value(raw: &str, command: &str) -> Result { pub async fn handle_skill_metadata_read( request: proto::SkillMetadataReadRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || system_read_skill_metadata_sync(request.path)) + crate::compat::async_runtime::spawn_blocking(move || system_read_skill_metadata_sync(request.path)) .await .map_err(|e| format!("gateway skill metadata read join failed: {e}"))? .map(|response| proto::SkillMetadataReadResponse { @@ -944,7 +944,7 @@ pub async fn handle_skill_text_read( .ok() .filter(|value| *value > 0); - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { system_read_skill_text_sync(request.path, offset, length) }) .await @@ -965,7 +965,7 @@ pub async fn handle_skill_manage( .map_err(|e| format!("invalid skill manage payload JSON: {e}"))? }; - tauri::async_runtime::spawn_blocking(move || system_manage_skill_sync(payload)) + crate::compat::async_runtime::spawn_blocking(move || system_manage_skill_sync(payload)) .await .map_err(|e| format!("gateway skill manage join failed: {e}"))? .and_then(|response| { diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index 4e49572c7..d6ad1ecbf 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod provider_usage; pub mod proxy; pub mod skills; pub mod system_proxy; +#[cfg(feature = "desktop")] pub mod tray; pub mod tunnel; pub mod workspace_watch; diff --git a/crates/agent-gui/src-tauri/src/services/proxy.rs b/crates/agent-gui/src-tauri/src/services/proxy.rs index 5deb2d46e..8299f0674 100644 --- a/crates/agent-gui/src-tauri/src/services/proxy.rs +++ b/crates/agent-gui/src-tauri/src/services/proxy.rs @@ -64,19 +64,18 @@ pub struct ProxyServerState { } #[derive(Deserialize)] -struct ProxyRoutePath { +pub(crate) struct ProxyRoutePath { provider: String, #[serde(rename = "rest")] _rest: Option, } #[derive(Deserialize)] -struct ImageProxyQuery { +pub(crate) struct ImageProxyQuery { url: String, } -#[tauri::command] -pub fn proxy_get_server_info(state: tauri::State<'_, Arc>) -> ProxyServerInfo { +pub fn proxy_get_server_info(state: &Arc) -> ProxyServerInfo { state.info.clone() } @@ -104,10 +103,11 @@ pub fn start_proxy_server() -> Result, String> { let app = Router::new() .route("/image-proxy", get(handle_image_proxy)) .route("/proxy/{provider}", any(handle_proxy)) + .route("/proxy/{provider}/", any(handle_proxy)) .route("/proxy/{provider}/{*rest}", any(handle_proxy)) .with_state(state.clone()); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let listener = match TokioTcpListener::from_std(listener) { Ok(listener) => listener, Err(err) => { @@ -123,7 +123,7 @@ pub fn start_proxy_server() -> Result, String> { Ok(state) } -async fn handle_image_proxy(Query(query): Query, headers: HeaderMap) -> Response { +pub async fn handle_image_proxy(Query(query): Query, headers: HeaderMap) -> Response { let target_url = match validate_image_proxy_url(&query.url) { Ok(url) => url, Err(message) => return error_response(StatusCode::BAD_REQUEST, &message, &headers), @@ -321,7 +321,7 @@ fn resolve_image_proxy_mime( Err("Image proxy upstream response is not a supported image".to_string()) } -async fn handle_proxy( +pub async fn handle_proxy( State(state): State>, Path(ProxyRoutePath { provider, .. }): Path, method: Method, diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs index b3e0c4f73..e0aa17f62 100644 --- a/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs @@ -179,7 +179,7 @@ impl GatewayController { pub(crate) fn start_tunnel_store(self: &Arc) { let controller = Arc::clone(self); self.tunnel_store_once.call_once(move || { - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { if let Err(error) = controller.tunnel_store().initialize().await { eprintln!("initialize gateway tunnel store failed: {error}"); } @@ -225,7 +225,7 @@ impl GatewayController { /// follows a desired-state publish within the timeout. fn watch_tunnel_gateway_support(self: &Arc, epoch: u64) { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { tokio::time::sleep(TUNNEL_GATEWAY_SUPPORT_TIMEOUT).await; match controller .tunnel_store() @@ -243,7 +243,7 @@ impl GatewayController { snapshot: proto::TunnelStateSnapshot, ) { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { match controller.tunnel_store().record_snapshot(&snapshot) { Ok(changed_specs) => { for spec in changed_specs { @@ -263,7 +263,7 @@ impl GatewayController { mutation: proto::TunnelMutation, ) { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let action = mutation.action.trim().to_ascii_lowercase(); let requested_tunnel_id = mutation.tunnel_id.trim().to_string(); let result = match action.as_str() { @@ -464,7 +464,7 @@ impl GatewayController { bypass_throttle: bool, ) { let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { controller .run_tunnel_probes(tunnel_ids, bypass_throttle) .await; diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs b/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs index 7bf985ea3..cd13878ae 100644 --- a/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs +++ b/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs @@ -127,7 +127,7 @@ impl TunnelProxy { } proto::TunnelFrameKind::Ping => { let controller = Arc::clone(controller); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let _ = send_tunnel_frame( &controller, proto::TunnelFrame { @@ -163,7 +163,7 @@ impl TunnelProxy { let controller = Arc::clone(controller); let stream_id = stream_id.to_string(); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { run_tunnel_http_request( controller, stream_id, @@ -194,7 +194,7 @@ impl TunnelProxy { let controller = Arc::clone(controller); let stream_id = stream_id.to_string(); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { run_tunnel_websocket(controller, stream_id, upstream_url, headers, gateway_rx).await; }); Ok(()) @@ -215,7 +215,7 @@ impl TunnelProxy { fn spawn_tunnel_frame_error(controller: &Arc, stream_id: String, error: String) { let controller = Arc::clone(controller); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let _ = send_tunnel_frame( &controller, proto::TunnelFrame { @@ -235,7 +235,7 @@ fn spawn_tunnel_ws_dial_error( error: String, ) { let controller = Arc::clone(controller); - tauri::async_runtime::spawn(async move { + crate::compat::async_runtime::spawn(async move { let _ = send_tunnel_frame( &controller, proto::TunnelFrame { diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/store.rs b/crates/agent-gui/src-tauri/src/services/tunnel/store.rs index 88f5141ee..9aeeb0d05 100644 --- a/crates/agent-gui/src-tauri/src/services/tunnel/store.rs +++ b/crates/agent-gui/src-tauri/src/services/tunnel/store.rs @@ -3,18 +3,20 @@ //! `TunnelDesiredState`. use std::collections::HashMap; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::Engine as _; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; -use tauri::Emitter; + use uuid::Uuid; use crate::commands::settings::open_db; use crate::runtime::project_path::project_path_key as normalize_project_path_key; use crate::services::gateway::{now_unix_seconds, proto}; +use crate::events::EventEmitter; +use crate::events::EventEmitterExt; use super::{ tunnel_health_payload_from_proto, validate_tunnel_target_url, GatewayTunnelCreateInput, @@ -63,14 +65,14 @@ struct TunnelStoreState { } pub struct TunnelStore { - app_handle: tauri::AppHandle, + event_emitter: Arc, state: Mutex, } impl TunnelStore { - pub fn new(app_handle: tauri::AppHandle) -> Self { + pub fn new(event_emitter: Arc) -> Self { Self { - app_handle, + event_emitter, state: Mutex::new(TunnelStoreState::default()), } } @@ -249,7 +251,7 @@ impl TunnelStore { fn emit_state(&self, payload: &TunnelStatePayload) { if let Err(error) = self - .app_handle + .event_emitter .emit(GATEWAY_TUNNEL_STATE_EVENT, payload.clone()) { eprintln!("emit gateway tunnel state failed: {error}"); @@ -532,7 +534,7 @@ fn now_ms() -> i64 { } async fn load_tunnel_specs() -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; load_tunnel_specs_sync(&conn) }) @@ -541,7 +543,7 @@ async fn load_tunnel_specs() -> Result, String> { } pub(super) async fn persist_tunnel_spec(spec: StoredTunnelSpec) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; persist_tunnel_spec_sync(&conn, &spec) }) @@ -553,7 +555,7 @@ pub(super) async fn delete_tunnel_specs(tunnel_ids: Vec) -> Result<(), S if tunnel_ids.is_empty() { return Ok(()); } - tauri::async_runtime::spawn_blocking(move || { + crate::compat::async_runtime::spawn_blocking(move || { let conn = open_db()?; for tunnel_id in &tunnel_ids { delete_tunnel_spec_sync(&conn, tunnel_id)?; diff --git a/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs b/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs index f8e3f8e8e..fade43181 100644 --- a/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs +++ b/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs @@ -4,8 +4,8 @@ //! thread — a dropped event is healed by the next change). use serde::Serialize; -use tauri::Emitter; +use crate::events::EventEmitterExt; use crate::services::gateway::{now_unix_seconds, proto}; use super::{WorkspaceWatchService, WORKSPACE_ACTIVITY_EVENT}; @@ -43,7 +43,7 @@ impl WorkspaceWatchService { }; if let Err(error) = self - .app_handle + .event_emitter .emit(WORKSPACE_ACTIVITY_EVENT, payload.clone()) { eprintln!("emit workspace activity failed: {error}"); diff --git a/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs b/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs index e8b305953..2f8653d51 100644 --- a/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs @@ -16,6 +16,7 @@ use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, Mutex, Weak}; use crate::services::gateway::GatewayController; +use crate::events::EventEmitter; pub const WORKSPACE_ACTIVITY_EVENT: &str = "workspace:activity"; @@ -35,7 +36,7 @@ struct WatchInner { } pub struct WorkspaceWatchService { - app_handle: tauri::AppHandle, + event_emitter: Arc, gateway: Mutex>>, inner: Mutex, // Per-workdir monotonic revision counters. Kept outside WatchInner so they @@ -45,9 +46,9 @@ pub struct WorkspaceWatchService { } impl WorkspaceWatchService { - pub fn new(app_handle: tauri::AppHandle) -> Self { + pub fn new(event_emitter: Arc) -> Self { Self { - app_handle, + event_emitter, gateway: Mutex::new(None), inner: Mutex::new(WatchInner::default()), revisions: Mutex::new(HashMap::new()), diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 7abba8004..ee1358fa4 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -1,6 +1,4 @@ import type { Context } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppErrorBoundary } from "./components/AppErrorBoundary"; import { CronPromptRunner } from "./components/cron/CronPromptRunner"; @@ -35,6 +33,7 @@ import { } from "./lib/settings/sync"; import { applyStoredGlobalShortcuts } from "./lib/shortcuts/globalShortcuts"; import { applyFontFamilies } from "./lib/system/fontFamily"; +import { invoke, listen } from "./lib/tauriBridge"; import { ChatPage } from "./pages/ChatPage"; import { SettingsPage } from "./pages/SettingsPage"; import type { SectionId } from "./pages/settings/types"; diff --git a/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx b/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx index 4a8d798ee..8c9d30cd5 100644 --- a/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx +++ b/crates/agent-gui/src/components/MacOsTitleBarSpacer.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; import type { AppUpdateController } from "../lib/appUpdates"; import { cn } from "../lib/shared/utils"; +import { invoke } from "../lib/tauriBridge"; import { AppUpdateButton } from "./AppUpdateButton"; import { PanelLeft, PanelLeftClose, Settings } from "./icons"; diff --git a/crates/agent-gui/src/components/Markdown.tsx b/crates/agent-gui/src/components/Markdown.tsx index 30aad7a19..31ba05cb8 100644 --- a/crates/agent-gui/src/components/Markdown.tsx +++ b/crates/agent-gui/src/components/Markdown.tsx @@ -2,7 +2,6 @@ import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { type ComponentProps, cloneElement, @@ -37,6 +36,7 @@ import { } from "../lib/markdownCodeBlockPolicy"; import { normalizeLatexDelimiters } from "../lib/normalizeLatexDelimiters"; import { cn } from "../lib/shared/utils"; +import { openUrl } from "../lib/tauriBridge"; import { Check, ChevronDown, ChevronUp, Copy, ExternalLink, X } from "./icons"; import { Button } from "./ui/button"; diff --git a/crates/agent-gui/src/components/WindowsTitleBar.tsx b/crates/agent-gui/src/components/WindowsTitleBar.tsx index 8597011a8..f6c01828f 100644 --- a/crates/agent-gui/src/components/WindowsTitleBar.tsx +++ b/crates/agent-gui/src/components/WindowsTitleBar.tsx @@ -1,9 +1,8 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; - import iconSimpleUrl from "../../src-tauri/icons/icon-simple.png"; import { useLocale } from "../i18n"; import { cn } from "../lib/shared/utils"; +import { getCurrentWindow } from "../lib/tauriBridge"; import { Maximize2, Minimize2, Minus, X } from "./icons"; type TauriRuntimeWindow = Window & { diff --git a/crates/agent-gui/src/components/chat/MentionComposer.tsx b/crates/agent-gui/src/components/chat/MentionComposer.tsx index d3173f9f2..af6df8c54 100644 --- a/crates/agent-gui/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gui/src/components/chat/MentionComposer.tsx @@ -1,4 +1,3 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { type ClipboardEvent, type FocusEvent, @@ -35,6 +34,7 @@ import { import { createUuid } from "../../lib/shared/id"; import { cn } from "../../lib/shared/utils"; import { readClipboardText } from "../../lib/system/clipboardText"; +import { openUrl } from "../../lib/tauriBridge"; import { invokeFs } from "../../lib/tools/fsBackend"; import { Blend, ClipboardPaste, Copy, ScanText, Scissors, SKILL_ICON_SVG_MARKUP } from "../icons"; import { getFileTypeIcon, getFileTypeIconSvg } from "./fileTypeIcons"; diff --git a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx index fb9777194..579fff7b1 100644 --- a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx +++ b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx @@ -1,5 +1,4 @@ import type { Context } from "@earendil-works/pi-ai"; -import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; import type { CompletePromptRunInput, PromptRunRequest } from "../../lib/automation"; import { backend } from "../../lib/automation/backend"; @@ -20,6 +19,7 @@ import { isAlwaysEnabledSkillName, type SkillSummary, } from "../../lib/skills"; +import { listen } from "../../lib/tauriBridge"; import { buildBuiltinToolRegistry } from "../../lib/tools/builtinRegistry"; import { createFileToolState } from "../../lib/tools/fileToolState"; import type { SkillAccessPolicy } from "../../lib/tools/skillAccessPolicy"; diff --git a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx index 1d8fbecc2..808df1622 100644 --- a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx @@ -1,4 +1,3 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { type CSSProperties, memo, @@ -22,6 +21,7 @@ import type { SshHostConfig, } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; +import { openUrl } from "../../lib/tauriBridge"; import type { TerminalClient, TerminalSession } from "../../lib/terminal/types"; import type { WorkspaceActivityClient } from "../../lib/workspace-activity/types"; import { X } from "../icons"; diff --git a/crates/agent-gui/src/components/project-tools/git-review/HistoryView.tsx b/crates/agent-gui/src/components/project-tools/git-review/HistoryView.tsx index dff07e2da..6a3158aef 100644 --- a/crates/agent-gui/src/components/project-tools/git-review/HistoryView.tsx +++ b/crates/agent-gui/src/components/project-tools/git-review/HistoryView.tsx @@ -7,7 +7,6 @@ // relative or @tauri-apps/* imports are allowed here. import { useVirtualizer } from "@tanstack/react-virtual"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { type MouseEvent as ReactMouseEvent, type UIEvent as ReactUIEvent, @@ -28,6 +27,7 @@ import { } from "../../../lib/git/gitGraph"; import type { GitCommitFile, GitCommitSummary } from "../../../lib/git/types"; import { cn } from "../../../lib/shared/utils"; +import { openUrl } from "../../../lib/tauriBridge"; import { getFileTypeIcon } from "../../chat/fileTypeIcons"; import { Cloud, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 09fd722a7..daa1e4f8a 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -98,6 +98,18 @@ export const translations: Record> = { "chat.workspaceRemoveRunning": "后台任务运行中,暂时不能移除。", "chat.workspaceRemoveDescription": "会删除此工作空间下的历史对话,不会删除文件夹。", "chat.workspaceOpenSystemFileManagerFailed": "打开资源管理器失败", + "folderPicker.description": "浏览并选择本地文件夹。", + "folderPicker.breadcrumb": "当前路径", + "folderPicker.up": "上一级", + "folderPicker.toggleHidden": "显示或隐藏隐藏目录", + "folderPicker.places": "快捷位置", + "folderPicker.loading": "正在加载目录…", + "folderPicker.empty": "此目录为空", + "folderPicker.truncated": "目录较多,仅显示前 2000 项", + "folderPicker.pathPlaceholder": "输入目录路径,按回车跳转", + "folderPicker.home": "主目录", + "folderPicker.chooseSelected": "选择所选文件夹", + "folderPicker.chooseThis": "选择此文件夹", "chat.exitConfirmTitle": "退出 LiveAgent?", "chat.exitConfirmSubtitle": "当前仍有终端任务在运行。", "chat.exitConfirmRunningLabel": "正在运行的 Terminal", @@ -2336,6 +2348,18 @@ export const translations: Record> = { "chat.workspaceRemoveDescription": "This deletes conversations under the workspace, but it does not delete the folder.", "chat.workspaceOpenSystemFileManagerFailed": "Failed to open the file manager", + "folderPicker.description": "Browse and choose a local folder.", + "folderPicker.breadcrumb": "Current path", + "folderPicker.up": "Up one level", + "folderPicker.toggleHidden": "Show or hide hidden directories", + "folderPicker.places": "Places", + "folderPicker.loading": "Loading directory…", + "folderPicker.empty": "This directory is empty", + "folderPicker.truncated": "Too many entries; showing the first 2000", + "folderPicker.pathPlaceholder": "Type a path and press Enter", + "folderPicker.home": "Home", + "folderPicker.chooseSelected": "Choose Selected", + "folderPicker.chooseThis": "Choose This Folder", "chat.exitConfirmTitle": "Exit LiveAgent?", "chat.exitConfirmSubtitle": "Terminal tasks are still running.", "chat.exitConfirmRunningLabel": "Running Terminal sessions", diff --git a/crates/agent-gui/src/lib/appUpdates.ts b/crates/agent-gui/src/lib/appUpdates.ts index 2e9bdff44..29f46b855 100644 --- a/crates/agent-gui/src/lib/appUpdates.ts +++ b/crates/agent-gui/src/lib/appUpdates.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "../lib/tauriBridge"; export type AppUpdateChannel = "stable" | "prerelease"; diff --git a/crates/agent-gui/src/lib/automation/backend.ts b/crates/agent-gui/src/lib/automation/backend.ts index 1d1b792f2..00476764e 100644 --- a/crates/agent-gui/src/lib/automation/backend.ts +++ b/crates/agent-gui/src/lib/automation/backend.ts @@ -3,8 +3,7 @@ // is the per-platform adapter — the web frontend ships its own copy speaking // the gateway cron.manage protocol. -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { AutomationApplyInput, diff --git a/crates/agent-gui/src/lib/automation/hookRunner.ts b/crates/agent-gui/src/lib/automation/hookRunner.ts index 221e37497..b336e1f0a 100644 --- a/crates/agent-gui/src/lib/automation/hookRunner.ts +++ b/crates/agent-gui/src/lib/automation/hookRunner.ts @@ -3,7 +3,7 @@ // conversation run owns a cancellable scope: aborting the run drops its // queued hooks and kills its in-flight script via the Rust scope registry. -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { createUuid } from "../shared/id"; import type { HookDef, HookEvent, HookType } from "./types"; diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 53b37b635..f2437e8d6 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -1,5 +1,5 @@ import type { Message } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../../lib/tauriBridge"; import { normalizeConversationSystemPrompt } from "../context/systemPrompt"; import { type ConversationViewState, diff --git a/crates/agent-gui/src/lib/chat/messages/userMessageContent.tsx b/crates/agent-gui/src/lib/chat/messages/userMessageContent.tsx index 041965821..aa21c64d5 100644 --- a/crates/agent-gui/src/lib/chat/messages/userMessageContent.tsx +++ b/crates/agent-gui/src/lib/chat/messages/userMessageContent.tsx @@ -1,4 +1,3 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { type FocusEvent, type MouseEvent, @@ -15,6 +14,7 @@ import { getFileTypeIcon } from "../../../components/chat/fileTypeIcons"; import { mentionChipClassName } from "../../../components/chat/mentionChipStyles"; import { SkillIcon } from "../../../components/icons"; import { useLocale } from "../../../i18n"; +import { openUrl } from "../../../lib/tauriBridge"; import { type CodeMentionReference, diff --git a/crates/agent-gui/src/lib/chat/openChatFileLink.ts b/crates/agent-gui/src/lib/chat/openChatFileLink.ts index 1071b913a..8beccd4d0 100644 --- a/crates/agent-gui/src/lib/chat/openChatFileLink.ts +++ b/crates/agent-gui/src/lib/chat/openChatFileLink.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { ChatFileLink } from "./chatFileLinks"; diff --git a/crates/agent-gui/src/lib/debug/agentDebug.ts b/crates/agent-gui/src/lib/debug/agentDebug.ts index 0900623ac..437ddf6e0 100644 --- a/crates/agent-gui/src/lib/debug/agentDebug.ts +++ b/crates/agent-gui/src/lib/debug/agentDebug.ts @@ -1,5 +1,5 @@ import type { Context } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { CodexRequestFormat, ExecutionMode, ProviderId, ReasoningLevel } from "../settings"; diff --git a/crates/agent-gui/src/lib/git/tauriGitClient.ts b/crates/agent-gui/src/lib/git/tauriGitClient.ts index f0ecd145c..790826b1a 100644 --- a/crates/agent-gui/src/lib/git/tauriGitClient.ts +++ b/crates/agent-gui/src/lib/git/tauriGitClient.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { type GitClient, normalizeGitBranchesResponse, diff --git a/crates/agent-gui/src/lib/managed-process/backend.ts b/crates/agent-gui/src/lib/managed-process/backend.ts index 9b092e762..dcab6714f 100644 --- a/crates/agent-gui/src/lib/managed-process/backend.ts +++ b/crates/agent-gui/src/lib/managed-process/backend.ts @@ -3,8 +3,7 @@ // is the per-platform adapter — the web frontend ships its own copy speaking // the gateway process.* protocol. -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { ManagedProcessBackend, ManagedProcessRecord, ManagedProcessState } from "./types"; diff --git a/crates/agent-gui/src/lib/memory/api.ts b/crates/agent-gui/src/lib/memory/api.ts index 91c73e686..bcef905cc 100644 --- a/crates/agent-gui/src/lib/memory/api.ts +++ b/crates/agent-gui/src/lib/memory/api.ts @@ -3,7 +3,7 @@ // shim intercepts every `memory_*` command and forwards it over the websocket // to the connected desktop agent. -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { ApplyDecision, MemoryConfidence, diff --git a/crates/agent-gui/src/lib/providers/nativeResponsesAttachments.ts b/crates/agent-gui/src/lib/providers/nativeResponsesAttachments.ts index 9b49d2665..fc9384c01 100644 --- a/crates/agent-gui/src/lib/providers/nativeResponsesAttachments.ts +++ b/crates/agent-gui/src/lib/providers/nativeResponsesAttachments.ts @@ -1,5 +1,5 @@ import type { Context, Model } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { getUserMessageAttachments, diff --git a/crates/agent-gui/src/lib/providers/proxy.ts b/crates/agent-gui/src/lib/providers/proxy.ts index 5a84c7ec5..74ee38c61 100644 --- a/crates/agent-gui/src/lib/providers/proxy.ts +++ b/crates/agent-gui/src/lib/providers/proxy.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "../../lib/tauriBridge"; import type { ProviderId } from "../settings"; @@ -78,6 +78,14 @@ function normalizeProxyServerInfo(info: ProxyServerInfo): ProxyServerInfo { async function getProxyServerInfo(): Promise { if (!proxyServerInfoPromise) { proxyServerInfoPromise = invoke("proxy_get_server_info") + .then((info) => { + if (!isTauri()) { + // headless(BFF):反代路由挂在主 HTTP 服务上,baseUrl 用页面 origin + // (同机或远程浏览器都正确,无需硬编码主机);token 沿用服务端随机 token。 + return { baseUrl: window.location.origin, token: info.token }; + } + return info; + }) .then(normalizeProxyServerInfo) .catch((error) => { proxyServerInfoPromise = null; diff --git a/crates/agent-gui/src/lib/providers/usageQuery.ts b/crates/agent-gui/src/lib/providers/usageQuery.ts index 5b53050fd..a1f9f2118 100644 --- a/crates/agent-gui/src/lib/providers/usageQuery.ts +++ b/crates/agent-gui/src/lib/providers/usageQuery.ts @@ -1,6 +1,6 @@ // 平台传输适配层:GUI 端用量查询直接走 Tauri invoke,由桌面端执行 API-only 查询。 // 共享的状态归约/协调器/hook 逻辑在 usageQueryCore.ts(两端字节镜像),本文件只放平台差异。 -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { UsageQueryConfig } from "../settings"; import { type ProviderUsageResult, diff --git a/crates/agent-gui/src/lib/runtimePlatform.ts b/crates/agent-gui/src/lib/runtimePlatform.ts index e0b0fbd56..46e0c2135 100644 --- a/crates/agent-gui/src/lib/runtimePlatform.ts +++ b/crates/agent-gui/src/lib/runtimePlatform.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../lib/tauriBridge"; export type RuntimePlatform = "windows" | "macos" | "linux"; diff --git a/crates/agent-gui/src/lib/settings/storage.ts b/crates/agent-gui/src/lib/settings/storage.ts index 27aa9a3df..c0e3744de 100644 --- a/crates/agent-gui/src/lib/settings/storage.ts +++ b/crates/agent-gui/src/lib/settings/storage.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { type Locale, normalizeLocale } from "../../i18n/config"; +import { invoke } from "../../lib/tauriBridge"; import { type AppSettings, diff --git a/crates/agent-gui/src/lib/sftp/tauriSftpClient.ts b/crates/agent-gui/src/lib/sftp/tauriSftpClient.ts index 370dd4e2a..3b7ece073 100644 --- a/crates/agent-gui/src/lib/sftp/tauriSftpClient.ts +++ b/crates/agent-gui/src/lib/sftp/tauriSftpClient.ts @@ -1,5 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { SftpActionResponse, SftpClient, diff --git a/crates/agent-gui/src/lib/shortcuts/globalShortcuts.ts b/crates/agent-gui/src/lib/shortcuts/globalShortcuts.ts index 29a13a4df..4c0c66ccb 100644 --- a/crates/agent-gui/src/lib/shortcuts/globalShortcuts.ts +++ b/crates/agent-gui/src/lib/shortcuts/globalShortcuts.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; /** * 全局快捷键(桌面端专属能力)。 diff --git a/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts b/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts index 90dc2b563..fff8b1852 100644 --- a/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts +++ b/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts @@ -2,7 +2,7 @@ // IPC surface and the single CHAT_HISTORY_SYNC_EVENT subscription. This file // is NOT mirrored — it is the desktop end's platform boundary. -import { listen } from "@tauri-apps/api/event"; +import { listen } from "../../lib/tauriBridge"; import type { ChatHistorySummary } from "../chat/history/chatHistory"; import { deleteChatHistory, diff --git a/crates/agent-gui/src/lib/skills/index.ts b/crates/agent-gui/src/lib/skills/index.ts index 0ef700f8e..2167745ef 100644 --- a/crates/agent-gui/src/lib/skills/index.ts +++ b/crates/agent-gui/src/lib/skills/index.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { sortSkillsForDisplay } from "./builtin"; import type { ClawHubSkillCard } from "./clawHub"; diff --git a/crates/agent-gui/src/lib/subagents/ipc/store.ts b/crates/agent-gui/src/lib/subagents/ipc/store.ts index d0113217d..9563d810f 100644 --- a/crates/agent-gui/src/lib/subagents/ipc/store.ts +++ b/crates/agent-gui/src/lib/subagents/ipc/store.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../../lib/tauriBridge"; import type { SubagentIdentity, diff --git a/crates/agent-gui/src/lib/subagents/ipc/worktree.ts b/crates/agent-gui/src/lib/subagents/ipc/worktree.ts index 2d39023ae..59bca999e 100644 --- a/crates/agent-gui/src/lib/subagents/ipc/worktree.ts +++ b/crates/agent-gui/src/lib/subagents/ipc/worktree.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../../lib/tauriBridge"; import type { SubagentWorktreeApplyResult, diff --git a/crates/agent-gui/src/lib/system/clipboardText.ts b/crates/agent-gui/src/lib/system/clipboardText.ts index 32025bdeb..18dbff740 100644 --- a/crates/agent-gui/src/lib/system/clipboardText.ts +++ b/crates/agent-gui/src/lib/system/clipboardText.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; /** * 桌面端自定义菜单"粘贴"的唯一剪贴板读取入口。 diff --git a/crates/agent-gui/src/lib/system/powerActivity.ts b/crates/agent-gui/src/lib/system/powerActivity.ts index 2161eeabc..74ddbdfd7 100644 --- a/crates/agent-gui/src/lib/system/powerActivity.ts +++ b/crates/agent-gui/src/lib/system/powerActivity.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import { createUuid } from "../shared/id"; diff --git a/crates/agent-gui/src/lib/tauriBridge.ts b/crates/agent-gui/src/lib/tauriBridge.ts new file mode 100644 index 000000000..70b65c422 --- /dev/null +++ b/crates/agent-gui/src/lib/tauriBridge.ts @@ -0,0 +1,300 @@ +/** + * Same-interface transport bridge between the Tauri desktop runtime and the + * headless server. + * + * The frontend imports invoke/listen/openUrl/etc. from this module instead of + * `@tauri-apps/*` directly. At runtime it detects whether it is inside the + * Tauri webview: + * + * - Tauri runtime -> delegates to the real `@tauri-apps` implementation + * (dynamic import so the headless browser bundle never + * touches Tauri code paths). + * - Plain browser -> talks to the headless server over HTTP + * (POST /api/invoke) and WebSocket (GET /ws), so the same + * WebUI build can drive LiveAgent headless. + * + * The headless server URL is resolved from (in order): + * `import.meta.env.VITE_LIVEAGENT_HEADLESS_URL` + * `window.__LIVEAGENT_HEADLESS_URL__` + * `window.location.origin` (same-origin: WebUI served by the headless + * server itself on a single port) + * `http://127.0.0.1:17890` + */ + +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { homeDir as tauriHomeDir } from "@tauri-apps/api/path"; +import { getCurrentWebview as tauriGetCurrentWebview } from "@tauri-apps/api/webview"; +import { getCurrentWindow as tauriGetCurrentWindow } from "@tauri-apps/api/window"; + +export { isTauri }; + +declare global { + interface Window { + __LIVEAGENT_HEADLESS_URL__?: string; + } +} + +function isTauriRuntime(): boolean { + if (typeof window === "undefined") return false; + const runtimeWindow = window as Window & { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }; + return runtimeWindow.__TAURI__ !== undefined || runtimeWindow.__TAURI_INTERNALS__ !== undefined; +} + +function isTauri(): boolean { + return isTauriRuntime(); +} + +export function resolveHeadlessBaseUrl(): string { + const fromEnv = import.meta.env.VITE_LIVEAGENT_HEADLESS_URL as string | undefined; + if (fromEnv) return fromEnv.replace(/\/+$/, ""); + if (typeof window !== "undefined" && window.__LIVEAGENT_HEADLESS_URL__) { + return window.__LIVEAGENT_HEADLESS_URL__.replace(/\/+$/, ""); + } + // Same-origin fallback: when the WebUI is served by the headless server + // itself (single-port deployment), talk to the origin we were loaded from. + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return "http://127.0.0.1:17890"; +} + +/** + * invoke() with the same signature as `@tauri-apps/api/core` invoke(). + * In a headless browser it POSTs to the headless server and normalizes the + * {ok, value|error} envelope back to Tauri-style promise semantics. + */ +export async function invoke(cmd: string, args?: Record): Promise { + if (isTauriRuntime()) { + const { invoke: tauriInvoke } = await import("@tauri-apps/api/core"); + return tauriInvoke(cmd, args as never); + } + + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(`${resolveHeadlessBaseUrl()}/api/invoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cmd, args: args ?? {} }), + }); + if (response.status === 429) { + if (attempt < maxRetries) { + // Exponential backoff: 500ms, 1500ms + const delay = 500 * (attempt + 1); + console.warn( + `[headless] invoke ${cmd} got 429, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`, + ); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + } + if (!response.ok) { + throw new Error(`headless invoke failed (HTTP ${response.status}) for command: ${cmd}`); + } + const body = (await response.json()) as { ok: boolean; value?: unknown; error?: string }; + if (!body.ok) { + throw new Error(body.error ?? `command failed: ${cmd}`); + } + return body.value as T; + } + throw new Error(`headless invoke failed after retries for command: ${cmd}`); +} + +// --------------------------------------------------------------------------- +// Event transport: Tauri `listen` vs. headless WebSocket fan-out. +// The headless server emits WS text frames shaped { event, payload }. +// --------------------------------------------------------------------------- + +type EventHandler = (event: { payload: unknown }) => void; + +const wsListeners = new Map>(); + +// Exponential-backoff reconnect for the headless WebSocket fan-out. When the +// connection drops (server restart, network hiccup, or the server closing a +// slow client due to backpressure), the socket is re-established automatically +// so already-registered listeners keep receiving events without re-subscribing. +const RECONNECT_INITIAL_DELAY_MS = 500; +const RECONNECT_MAX_DELAY_MS = 10_000; + +let ws: WebSocket | null = null; +let wsConnectionPromise: Promise | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS; +let reconnectAttempts = 0; + +function hasListeners(): boolean { + for (const handlers of wsListeners.values()) { + if (handlers.size > 0) return true; + } + return false; +} + +/** Drop the socket and any pending reconnect. Called when the last listener unsubscribes. */ +function teardownHeadlessSocket(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws !== null) { + ws.onclose = null; + ws.onerror = null; + try { + ws.close(); + } catch { + // already closing/closed + } + ws = null; + } + wsConnectionPromise = null; + reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS; + reconnectAttempts = 0; +} + +function scheduleReconnect(): void { + if (reconnectTimer !== null) return; + const delay = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_DELAY_MS); + reconnectAttempts += 1; + console.warn( + `[headless] WebSocket lost; reconnecting in ${delay}ms (attempt ${reconnectAttempts})`, + ); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + // Fire-and-forget: on failure onclose schedules the next attempt. + void connectHeadlessWebSocket().catch(() => { + /* handled by scheduleReconnect */ + }); + }, delay); +} + +function connectHeadlessWebSocket(): Promise { + if (ws !== null && ws.readyState === WebSocket.OPEN) { + return Promise.resolve(ws); + } + if (wsConnectionPromise) { + return wsConnectionPromise; + } + + wsConnectionPromise = new Promise((resolve, reject) => { + const socket = new WebSocket(`${resolveHeadlessBaseUrl().replace(/^http/, "ws")}/ws`); + ws = socket; + + socket.onopen = () => { + reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS; + reconnectAttempts = 0; + console.info("[headless] WebSocket connected"); + resolve(socket); + }; + + socket.onerror = () => { + console.warn("[headless] WebSocket error (close will follow)"); + }; + + socket.onclose = (event) => { + if (ws === socket) ws = null; + wsConnectionPromise = null; + // Rejecting a promise that already resolved (connected then dropped) is a + // no-op; for a failed first connect it surfaces the error to listen(). + reject( + new Error( + `headless WebSocket closed (code ${event.code}${event.reason ? `: ${event.reason}` : ""})`, + ), + ); + scheduleReconnect(); + }; + + socket.onmessage = (message) => { + try { + const frame = JSON.parse(message.data as string) as { event?: string; payload?: unknown }; + if (typeof frame.event !== "string") return; + const handlers = wsListeners.get(frame.event); + if (!handlers) return; + for (const handler of [...handlers]) { + handler({ payload: frame.payload }); + } + } catch (error) { + console.error("[headless] failed to parse WS event frame", error); + } + }; + }); + + return wsConnectionPromise; +} + +/** + * listen() with the same signature as `@tauri-apps/api/event` listen(): + * returns a promise of an unlisten function. + */ +export async function listen( + event: string, + handler: (event: { payload: T }) => void, +): Promise { + if (isTauriRuntime()) { + const { listen: tauriListen } = await import("@tauri-apps/api/event"); + return tauriListen(event, handler); + } + + await connectHeadlessWebSocket(); + let handlers = wsListeners.get(event); + if (!handlers) { + handlers = new Set(); + wsListeners.set(event, handlers); + } + const wrapped = handler as EventHandler; + handlers.add(wrapped); + return () => { + handlers?.delete(wrapped); + if (!hasListeners()) teardownHeadlessSocket(); + }; +} + +// --------------------------------------------------------------------------- +// plugin-opener shim +// --------------------------------------------------------------------------- + +export async function openUrl(url: string): Promise { + if (isTauriRuntime()) { + const { openUrl: tauriOpenUrl } = await import("@tauri-apps/plugin-opener"); + return tauriOpenUrl(url); + } + // Browsers can open a new tab directly; no OS-level opener needed. + window.open(url, "_blank", "noopener,noreferrer"); +} + +export async function revealItemInDir(path: string): Promise { + if (isTauriRuntime()) { + const { revealItemInDir: tauriReveal } = await import("@tauri-apps/plugin-opener"); + return tauriReveal(path); + } + console.warn("[headless] revealItemInDir is not supported; path:", path); +} + +// --------------------------------------------------------------------------- +// Desktop-only API passthrough. Callers already guard with isTauri() before +// use (e.g. WindowsTitleBar, useTauriFileDrop), so these only touch the real +// implementation under the Tauri runtime. The static import is side-effect +// free; the modules expose plain functions and never read Tauri internals at +// import time, so shipping them in a headless browser bundle is safe. +// --------------------------------------------------------------------------- + +export function getCurrentWindow(): ReturnType { + if (!isTauriRuntime()) { + throw new Error("[headless] getCurrentWindow is only available in the Tauri runtime"); + } + return tauriGetCurrentWindow(); +} + +export function getCurrentWebview(): ReturnType { + if (!isTauriRuntime()) { + throw new Error("[headless] getCurrentWebview is only available in the Tauri runtime"); + } + return tauriGetCurrentWebview(); +} + +export function homeDir(): Promise { + if (!isTauriRuntime()) { + // The headless server resolves `~` itself inside the Rust fs commands, so the + // browser-side home dir is only used for pre-expansion. Return empty. + return Promise.resolve(""); + } + return tauriHomeDir(); +} diff --git a/crates/agent-gui/src/lib/terminal/tauriSshLocalForwardClient.ts b/crates/agent-gui/src/lib/terminal/tauriSshLocalForwardClient.ts index 136d11166..23f539193 100644 --- a/crates/agent-gui/src/lib/terminal/tauriSshLocalForwardClient.ts +++ b/crates/agent-gui/src/lib/terminal/tauriSshLocalForwardClient.ts @@ -1,5 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { RawSshLocalForwardAction, RawSshLocalForwardEvent, diff --git a/crates/agent-gui/src/lib/terminal/tauriTerminalClient.ts b/crates/agent-gui/src/lib/terminal/tauriTerminalClient.ts index fd17b6b82..c6e51e1be 100644 --- a/crates/agent-gui/src/lib/terminal/tauriTerminalClient.ts +++ b/crates/agent-gui/src/lib/terminal/tauriTerminalClient.ts @@ -1,5 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { SshTerminalTab, SshTerminalTabsSnapshot, diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 2cb2919eb..f56603609 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -1,5 +1,5 @@ import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { homeDir } from "@tauri-apps/api/path"; +import { homeDir } from "../../lib/tauriBridge"; import type { RuntimePlatform } from "../runtimePlatform"; import { type McpSettings, diff --git a/crates/agent-gui/src/lib/tools/fsBackend.ts b/crates/agent-gui/src/lib/tools/fsBackend.ts index a932fc3a1..fb1a36ad0 100644 --- a/crates/agent-gui/src/lib/tools/fsBackend.ts +++ b/crates/agent-gui/src/lib/tools/fsBackend.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; export type FsErrorCode = | "invalid_workdir" diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index 444f5e968..f00b01694 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -5,8 +5,8 @@ import type { ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import { type BuiltinToolBundle, type BuiltinToolResultDetails, diff --git a/crates/agent-gui/src/lib/tools/invokeWithAbort.ts b/crates/agent-gui/src/lib/tools/invokeWithAbort.ts index a1337e737..3a152dab8 100644 --- a/crates/agent-gui/src/lib/tools/invokeWithAbort.ts +++ b/crates/agent-gui/src/lib/tools/invokeWithAbort.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; type InvokeWithAbortOptions = { onAbort?: () => Promise | void; diff --git a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts index b8bd5ce75..d075c6cde 100644 --- a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts @@ -1,6 +1,6 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import { type McpServerConfig, diff --git a/crates/agent-gui/src/lib/tools/mcpTools.ts b/crates/agent-gui/src/lib/tools/mcpTools.ts index cc0370fb1..71bc06f8d 100644 --- a/crates/agent-gui/src/lib/tools/mcpTools.ts +++ b/crates/agent-gui/src/lib/tools/mcpTools.ts @@ -5,7 +5,7 @@ import type { ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "../../lib/tauriBridge"; import type { McpServerConfig } from "../settings"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 9d1b134cb..3469be9c9 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -1,6 +1,6 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import { inferRuntimePlatform, normalizeRuntimePlatform, diff --git a/crates/agent-gui/src/lib/tools/sshManagerTools.ts b/crates/agent-gui/src/lib/tools/sshManagerTools.ts index e0c23d83f..ee214dfac 100644 --- a/crates/agent-gui/src/lib/tools/sshManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/sshManagerTools.ts @@ -1,6 +1,6 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import type { SshHostConfig } from "../settings"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; diff --git a/crates/agent-gui/src/lib/tools/terminalTools.ts b/crates/agent-gui/src/lib/tools/terminalTools.ts index 0f5a2782d..18626ef84 100644 --- a/crates/agent-gui/src/lib/tools/terminalTools.ts +++ b/crates/agent-gui/src/lib/tools/terminalTools.ts @@ -1,6 +1,6 @@ import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; type TerminalReadTailResponse = { diff --git a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts index 625530994..b82707fb0 100644 --- a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts @@ -1,6 +1,6 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { invoke } from "../../lib/tauriBridge"; import { composePublicUrl, diff --git a/crates/agent-gui/src/lib/tray/trayMenu.ts b/crates/agent-gui/src/lib/tray/trayMenu.ts index 8ef30e092..643393d9a 100644 --- a/crates/agent-gui/src/lib/tray/trayMenu.ts +++ b/crates/agent-gui/src/lib/tray/trayMenu.ts @@ -10,8 +10,8 @@ * - 非 Tauri 环境(vite dev / WebUI 无此模块)invoke 失败静默。 */ -import { invoke } from "@tauri-apps/api/core"; import { type Locale, t } from "../../i18n/config"; +import { invoke } from "../../lib/tauriBridge"; import type { CronTask } from "../automation/types"; import type { AppSettings, Theme, WorkspaceProject } from "../settings"; import { workspaceProjectPathKey } from "../settings"; diff --git a/crates/agent-gui/src/lib/tunnels/tauriTunnelClient.ts b/crates/agent-gui/src/lib/tunnels/tauriTunnelClient.ts index 9bb815e15..1977ef76b 100644 --- a/crates/agent-gui/src/lib/tunnels/tauriTunnelClient.ts +++ b/crates/agent-gui/src/lib/tunnels/tauriTunnelClient.ts @@ -1,5 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { LocalTunnelClient, TunnelCreateInput, diff --git a/crates/agent-gui/src/lib/uploadReadableFiles.ts b/crates/agent-gui/src/lib/uploadReadableFiles.ts new file mode 100644 index 000000000..f4ac3fce9 --- /dev/null +++ b/crates/agent-gui/src/lib/uploadReadableFiles.ts @@ -0,0 +1,121 @@ +import type { PendingUploadedFile } from "./chat/messages/uploadedFiles"; +import { resolveHeadlessBaseUrl } from "./tauriBridge"; + +type ImportReadableFilesResponse = { + files: PendingUploadedFile[]; + skipped: string[]; +}; + +// Same protocol as the agent-gateway WebUI upload helper +// (crates/agent-gateway/web/src/lib/uploadReadableFiles.ts): multipart +// FormData → POST /api/files/import, server replies { files, skipped } with +// PendingUploadedFile entries. Headless mode has no gateway agent_id / Bearer +// token — the WebUI is served same-origin by the headless server, which +// exempts same-origin requests from token auth (matching /api/invoke). +// +// Gateway errors are JSON with an error/message field; anything else (a +// reverse proxy's HTML error page, a truncated body) must not leak into the +// UI verbatim — map it to a friendly message instead. +async function readFetchError(response: Response, fallback: string) { + const fallbackWithStatus = `${fallback}(HTTP ${response.status})`; + if (response.status === 413) { + return "文件过大,服务器拒绝接收(HTTP 413)。请压缩文件后重试,或调大 LIVEAGENT_HEADLESS_MAX_BODY_MB。"; + } + const raw = (await response.text().catch(() => "")).trim(); + if (!raw) { + return fallbackWithStatus; + } + + try { + const payload = JSON.parse(raw) as { error?: unknown; message?: unknown }; + const errorText = + typeof payload.error === "string" + ? payload.error.trim() + : typeof payload.message === "string" + ? payload.message.trim() + : ""; + return errorText || fallbackWithStatus; + } catch { + if (raw.startsWith("<") || raw.length > 300) { + return fallbackWithStatus; + } + return raw; + } +} + +function normalizeUploadedFile(value: unknown): PendingUploadedFile | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + const relativePath = typeof record.relativePath === "string" ? record.relativePath.trim() : ""; + const fileName = typeof record.fileName === "string" ? record.fileName.trim() : ""; + const kind = typeof record.kind === "string" ? record.kind.trim() : ""; + const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : NaN; + + if (!relativePath || !fileName || !kind || !Number.isFinite(sizeBytes)) { + return null; + } + + return { + relativePath, + absolutePath: + typeof record.absolutePath === "string" && record.absolutePath.trim() + ? record.absolutePath.trim() + : undefined, + fileName, + kind: kind as PendingUploadedFile["kind"], + sizeBytes, + }; +} + +/** + * Upload files to the headless server via multipart (POST /api/files/import). + * The browser sets the multipart boundary automatically; no Content-Type + * header should be set manually. `agent_id` is omitted — headless mode is + * single-agent and the server ignores it for protocol parity. + */ +export async function importReadableFilesViaMultipart( + workdir: string, + files: File[], +): Promise { + const normalizedWorkdir = workdir.trim(); + if (!normalizedWorkdir) { + throw new Error("项目目录未选择,无法导入文件。"); + } + if (files.length === 0) { + return { files: [], skipped: [] }; + } + + const formData = new FormData(); + formData.set("workdir", normalizedWorkdir); + for (const file of files) { + formData.append("files", file, file.name); + } + + const response = await fetch(`${resolveHeadlessBaseUrl()}/api/files/import`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(await readFetchError(response, "导入文件失败")); + } + + const payload = (await response.json()) as { + files?: unknown[]; + skipped?: unknown[]; + }; + + return { + files: Array.isArray(payload.files) + ? payload.files + .map(normalizeUploadedFile) + .filter((file): file is PendingUploadedFile => file !== null) + : [], + skipped: Array.isArray(payload.skipped) + ? payload.skipped.filter((item): item is string => typeof item === "string") + : [], + }; +} diff --git a/crates/agent-gui/src/lib/workspace-activity/tauriWorkspaceActivityClient.ts b/crates/agent-gui/src/lib/workspace-activity/tauriWorkspaceActivityClient.ts index 11d786f3d..c28e2f289 100644 --- a/crates/agent-gui/src/lib/workspace-activity/tauriWorkspaceActivityClient.ts +++ b/crates/agent-gui/src/lib/workspace-activity/tauriWorkspaceActivityClient.ts @@ -7,8 +7,7 @@ // with reference-counted listener lifecycle, mirroring the tauriTunnelClient // pattern in ChatPage. -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { invoke, listen } from "../../lib/tauriBridge"; import type { WorkspaceActivity, WorkspaceActivityClient } from "./types"; type WorkspaceActivityListener = Parameters[1]; diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 2297404af..d8ca85e4c 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1,5 +1,4 @@ import type { Context } from "@earendil-works/pi-ai"; -import { listen } from "@tauri-apps/api/event"; import { type CSSProperties, type SetStateAction, @@ -90,6 +89,7 @@ import { createSidebarStore } from "../lib/sidebar/store"; import { useSidebarSelector } from "../lib/sidebar/useSidebarSelector"; import { mergeAlwaysEnabledSkillNames } from "../lib/skills"; import { createSubagentStoreManager } from "../lib/subagents"; +import { listen } from "../lib/tauriBridge"; import { terminalSessionBelongsToProject } from "../lib/terminal/sessionStore"; import { tauriTerminalClient } from "../lib/terminal/tauriTerminalClient"; import { cancelPendingAskUserQuestionsForConversation } from "../lib/tools/askUserQuestionTools"; diff --git a/crates/agent-gui/src/pages/chat/composer/composerDraftText.ts b/crates/agent-gui/src/pages/chat/composer/composerDraftText.ts index 43b24ea2f..f1b7a9b8c 100644 --- a/crates/agent-gui/src/pages/chat/composer/composerDraftText.ts +++ b/crates/agent-gui/src/pages/chat/composer/composerDraftText.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import type { MentionComposerCommitMention, MentionComposerDraft, @@ -15,6 +14,7 @@ import { type PendingUploadedFile, withPastedTextDisplayMetadata, } from "../../../lib/chat/messages/uploadedFiles"; +import { invoke } from "../../../lib/tauriBridge"; type SystemImportPastedTextsResponse = { files: PendingUploadedFile[]; diff --git a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts index e4fe79881..9f02dc28f 100644 --- a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts +++ b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts @@ -1,10 +1,8 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; - import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; import { normalizeChatRuntimeControls } from "../../../lib/settings"; import { createUuid } from "../../../lib/shared/id"; +import { invoke, listen } from "../../../lib/tauriBridge"; import { type ActiveGatewayBridgeRequest, type GatewayBridgeRuntimeRefs, diff --git a/crates/agent-gui/src/pages/chat/gateway/useGatewayRunMirrorCoordinator.ts b/crates/agent-gui/src/pages/chat/gateway/useGatewayRunMirrorCoordinator.ts index 60d91ad45..28a5c07dd 100644 --- a/crates/agent-gui/src/pages/chat/gateway/useGatewayRunMirrorCoordinator.ts +++ b/crates/agent-gui/src/pages/chat/gateway/useGatewayRunMirrorCoordinator.ts @@ -1,9 +1,7 @@ import type { Message } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef } from "react"; - import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; +import { invoke, listen } from "../../../lib/tauriBridge"; import { buildGatewayRuntimeSnapshotEntries, type GatewayRuntimeSnapshotState, diff --git a/crates/agent-gui/src/pages/chat/gateway/useGatewayStatus.ts b/crates/agent-gui/src/pages/chat/gateway/useGatewayStatus.ts index c4e1d8be6..13f7929ef 100644 --- a/crates/agent-gui/src/pages/chat/gateway/useGatewayStatus.ts +++ b/crates/agent-gui/src/pages/chat/gateway/useGatewayStatus.ts @@ -1,7 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { useEffect, useState } from "react"; import type { AppSettings } from "../../../lib/settings"; +import { invoke, listen } from "../../../lib/tauriBridge"; import { buildFallbackGatewayStatus, type GatewayRuntimeStatus } from "./gatewayRuntimeStatusModel"; type UseGatewayStatusParams = { diff --git a/crates/agent-gui/src/pages/chat/history/useSharedHistory.ts b/crates/agent-gui/src/pages/chat/history/useSharedHistory.ts index 857b2b75a..1b0e6fcd2 100644 --- a/crates/agent-gui/src/pages/chat/history/useSharedHistory.ts +++ b/crates/agent-gui/src/pages/chat/history/useSharedHistory.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { type Dispatch, type SetStateAction, @@ -18,6 +17,7 @@ import { import type { AppSettings } from "../../../lib/settings"; import { sortSidebarConversations } from "../../../lib/sidebar/reconcile"; import type { SidebarStore } from "../../../lib/sidebar/store"; +import { invoke } from "../../../lib/tauriBridge"; import { asErrorMessage } from "../chatPageUtils"; import type { GatewayRuntimeStatus } from "../gateway/gatewayRuntimeStatusModel"; diff --git a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts index 950a11039..08d8f1fdd 100644 --- a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts @@ -1,12 +1,12 @@ -import { invoke } from "@tauri-apps/api/core"; import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; - import type { MentionComposerHandle } from "../../../components/chat/MentionComposer"; import type { NotifyItem } from "../../../components/chat/NotifyToast"; import { mergePendingUploadedFiles, type PendingUploadedFile, } from "../../../lib/chat/messages/uploadedFiles"; +import { invoke, isTauri } from "../../../lib/tauriBridge"; +import { importReadableFilesViaMultipart } from "../../../lib/uploadReadableFiles"; type SystemPickReadableFilesResponse = { files: PendingUploadedFile[]; @@ -56,6 +56,25 @@ async function fileToUploadInput(file: File): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = () => { + resolve(Array.from(input.files ?? [])); + }; + // Cancel / Escape dismisses the picker without selecting anything. + input.oncancel = () => resolve([]); + input.click(); + }); +} + export function usePendingUploads(params: UsePendingUploadsParams) { const { isAgentMode, @@ -290,11 +309,23 @@ export function usePendingUploads(params: UsePendingUploadsParams) { runUploadTask({ emptySelectionMessage: "所选文件均不受当前 Read 支持", errorFallback: "导入文件失败", - importer: ({ targetWorkdir, remainingFileSlots }) => - invoke("system_pick_readable_files", { + importer: async ({ targetWorkdir, remainingFileSlots }) => { + if (!isTauri()) { + // Headless: no native dialog — use the browser file picker and + // upload via the same multipart endpoint as drag & drop/paste + // (POST /api/files/import, agent-gateway-compatible protocol). + const picked = await pickBrowserFiles(); + if (picked.length === 0) return { files: [], skipped: [] }; + return importReadableFilesViaMultipart( + targetWorkdir, + picked.slice(0, remainingFileSlots), + ); + } + return invoke("system_pick_readable_files", { workdir: targetWorkdir, maxFiles: remainingFileSlots, - }), + }); + }, }), [runUploadTask], ); @@ -331,6 +362,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略 ${ignoredForLimit} 个额外文件`, ); } + if (!isTauri()) { + // Headless: multipart upload (POST /api/files/import), same + // protocol as the agent-gateway WebUI — no base64 expansion. + return importReadableFilesViaMultipart(targetWorkdir, importBatch); + } const uploadFiles = await Promise.all(importBatch.map(fileToUploadInput)); return invoke("system_import_uploaded_readable_files", { workdir: targetWorkdir, diff --git a/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts b/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts index 58f739ab4..ebddfc60d 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts @@ -1,6 +1,5 @@ -import { isTauri } from "@tauri-apps/api/core"; -import { getCurrentWebview } from "@tauri-apps/api/webview"; import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; +import { getCurrentWebview, isTauri } from "../../../lib/tauriBridge"; type UseTauriFileDropParams = { canDropUpload: boolean; diff --git a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts index 019575797..110035935 100644 --- a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts @@ -1,5 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MentionComposerDraft, @@ -14,6 +12,7 @@ import { isAgentExecutionMode, normalizeChatRuntimeControls, } from "../../../lib/settings"; +import { invoke, listen } from "../../../lib/tauriBridge"; import { answerAskUserQuestion } from "../../../lib/tools/askUserQuestionTools"; import { answerToolApproval } from "../../../lib/tools/toolApproval"; import type { ChatQueueTurnPreview } from "../components/ChatComposerBar"; diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 0362c2506..a21a32661 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -1,5 +1,4 @@ import type { Context, UserMessage } from "@earendil-works/pi-ai"; -import { invoke } from "@tauri-apps/api/core"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { useCallback } from "react"; import type { @@ -66,6 +65,7 @@ import { pruneSubagentRunsForConversation, type SubagentStoreManager, } from "../../../lib/subagents"; +import { invoke } from "../../../lib/tauriBridge"; import type { SkillAccessPolicy } from "../../../lib/tools/skillAccessPolicy"; import { appendManagedSkillSelections, asErrorMessage } from "../chatPageUtils"; import { diff --git a/crates/agent-gui/src/pages/chat/transcript/uploadedImagePreview.ts b/crates/agent-gui/src/pages/chat/transcript/uploadedImagePreview.ts index 04650206e..26a7e1abd 100644 --- a/crates/agent-gui/src/pages/chat/transcript/uploadedImagePreview.ts +++ b/crates/agent-gui/src/pages/chat/transcript/uploadedImagePreview.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; +import { invoke } from "../../../lib/tauriBridge"; type UploadedImagePreviewResponse = { mimeType: string; diff --git a/crates/agent-gui/src/pages/chat/workspace/HeadlessFolderPicker.tsx b/crates/agent-gui/src/pages/chat/workspace/HeadlessFolderPicker.tsx new file mode 100644 index 000000000..b2b35aeae --- /dev/null +++ b/crates/agent-gui/src/pages/chat/workspace/HeadlessFolderPicker.tsx @@ -0,0 +1,619 @@ +// Headless folder picker dialog (international-style directory selector). +// +// Used when running as a plain browser against the headless server: the +// desktop runtime gets a native folder dialog, but in headless mode there is +// no OS dialog, so this component offers a Finder/Explorer/VS Code style +// directory browser instead of a bare text input: +// +// - breadcrumb navigation (click any path segment to jump) +// - current-directory listing via fs_list_dirs (double-click to enter) +// - sidebar quick locations via fs_roots (root "/", home "~") +// - "up one level" button +// - editable path input (Enter to jump) +// - keyboard navigation (↑/↓ move, → enter, ← up, Enter confirm, Esc cancel) +// - hidden-directory toggle +// +// The imperative API mirrors `system_pick_folder`: call openFolderPicker() +// and await the chosen absolute path (or null when cancelled). + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { + ArrowLeft, + ChevronRight, + Eye, + EyeOff, + Folder, + FolderOpen, + FolderTree, + House, + Info, + Loader2, + X, +} from "../../../components/icons"; +import { DEFAULT_LOCALE, type Locale, t as translate } from "../../../i18n/config"; +import { cn } from "../../../lib/shared/utils"; +import { invokeFs } from "../../../lib/tools/fsBackend"; + +type FsDirEntry = { + path: string; + name: string; +}; + +type FsListDirsResponse = { + path: string; + entries: FsDirEntry[]; + truncated: boolean; +}; + +type FsRoot = { + id: string; + path: string; + kind: "home" | "root" | "drive"; + label: string; +}; + +type FsRootsResponse = { + roots: FsRoot[]; +}; + +type OpenFolderPickerOptions = { + title?: string; + initialPath?: string; +}; + +const UI_SETTINGS_STORAGE_KEY = "liveagent.ui-settings.v1"; + +function detectLocale(): Locale { + try { + const raw = localStorage.getItem(UI_SETTINGS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { locale?: unknown }; + const value = typeof parsed.locale === "string" ? parsed.locale : ""; + if (value === "zh-CN" || value === "en-US") return value; + } + } catch { + // fall through to default locale + } + return DEFAULT_LOCALE; +} + +const LIST_DIRS_LIMIT = 2000; + +/** Absolute parent of an absolute path; null when already at a filesystem root. */ +function parentPath(path: string): string | null { + const normalized = path.replace(/\\/g, "/"); + if (/^[A-Za-z]:\/$/.test(normalized)) return null; // "C:\" drive root + if (normalized === "/") return null; + const trimmed = normalized.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + if (idx <= 0) { + return /^[A-Za-z]:$/.test(trimmed) ? `${trimmed}/` : "/"; + } + return `${trimmed.slice(0, idx)}/`; +} + +/** Breadcrumb segments: [{label, path}] from filesystem root down to `path`. */ +function breadcrumbSegments(path: string): Array<{ label: string; path: string }> { + const normalized = path.replace(/\\/g, "/"); + if (/^[A-Za-z]:/.test(normalized)) { + const drive = normalized.slice(0, 2); // "C:" + const rest = normalized + .slice(2) + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const segments = [{ label: drive, path: `${drive}/` }]; + let acc = `${drive}/`; + for (const part of rest) { + acc += `${part}/`; + segments.push({ label: part, path: acc }); + } + return segments; + } + if (!normalized.startsWith("/")) { + return [{ label: normalized || "/", path: normalized }]; + } + const parts = normalized.split("/").filter(Boolean); + const segments = [{ label: "/", path: "/" }]; + let acc = "/"; + for (const part of parts) { + acc += `${part}/`; + segments.push({ label: part, path: acc }); + } + return segments; +} + +/** Best-effort "display name" of the last path segment (used in shortcuts). */ +function pathBaseName(path: string): string { + const trimmed = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +} + +function FolderPickerDialog({ + title, + initialPath, + locale, + onResolve, +}: { + title: string; + initialPath: string; + locale: Locale; + onResolve: (path: string | null) => void; +}) { + const t = useMemo(() => (key: string) => translate(key, locale), [locale]); + + const [currentPath, setCurrentPath] = useState(initialPath); + const [entries, setEntries] = useState([]); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [selectedPath, setSelectedPath] = useState(null); + const [roots, setRoots] = useState([]); + const [showHidden, setShowHidden] = useState(false); + const [pathInput, setPathInput] = useState(initialPath); + const [inputError, setInputError] = useState(""); + const listRef = useRef(null); + const inputRef = useRef(null); + const requestIdRef = useRef(0); + + // Quick locations: fs_roots + the initial path (so the user can jump back). + useEffect(() => { + let cancelled = false; + void invokeFs("fs_roots", {}) + .then((response) => { + if (!cancelled) setRoots(response.roots ?? []); + }) + .catch(() => { + // fs_roots is best-effort; fall back to only the initial shortcut. + }); + return () => { + cancelled = true; + }; + }, []); + + // Load the directory listing whenever the current path changes. + useEffect(() => { + const requestId = ++requestIdRef.current; + setLoading(true); + setError(""); + setSelectedPath(null); + setInputError(""); + setPathInput(currentPath); + void invokeFs("fs_list_dirs", { + path: currentPath, + max_results: LIST_DIRS_LIMIT, + }) + .then((response) => { + if (requestId !== requestIdRef.current) return; + setEntries(response.entries ?? []); + setTruncated(response.truncated === true); + }) + .catch((reason) => { + if (requestId !== requestIdRef.current) return; + setEntries([]); + setTruncated(false); + setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (requestId === requestIdRef.current) setLoading(false); + }); + }, [currentPath]); + + const visibleEntries = useMemo( + () => (showHidden ? entries : entries.filter((entry) => !entry.name.startsWith("."))), + [entries, showHidden], + ); + + const selectedIndex = useMemo(() => { + const idx = visibleEntries.findIndex((entry) => entry.path === selectedPath); + return idx; + }, [selectedPath, visibleEntries]); + + const confirmSelection = useCallback(() => { + // A highlighted entry wins (like Finder's "Choose" button); otherwise the + // current directory itself is selected. + if (selectedPath) { + onResolve(selectedPath); + } else { + onResolve(currentPath); + } + }, [currentPath, onResolve, selectedPath]); + + const enterDirectory = useCallback((path: string) => { + setCurrentPath(path); + }, []); + + const goUp = useCallback(() => { + const parent = parentPath(currentPath); + if (parent) setCurrentPath(parent); + }, [currentPath]); + + const jumpTo = useCallback((path: string) => { + setCurrentPath(path); + }, []); + + // Keyboard navigation: ↑/↓ move, → enter, ← up, Enter confirm, Esc cancel. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onResolve(null); + return; + } + if (loading) return; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + if (visibleEntries.length === 0) return; + const delta = event.key === "ArrowUp" ? -1 : 1; + const nextIndex = + selectedIndex < 0 + ? delta > 0 + ? 0 + : visibleEntries.length - 1 + : (selectedIndex + delta + visibleEntries.length) % visibleEntries.length; + setSelectedPath(visibleEntries[nextIndex]?.path ?? null); + listRef.current + ?.querySelector(`[data-path="${CSS.escape(visibleEntries[nextIndex]?.path ?? "")}"]`) + ?.scrollIntoView({ block: "nearest" }); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + const selected = visibleEntries[selectedIndex]; + if (selected) enterDirectory(selected.path); + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + goUp(); + return; + } + if (event.key === "Enter" && document.activeElement !== inputRef.current) { + event.preventDefault(); + confirmSelection(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [confirmSelection, enterDirectory, goUp, loading, onResolve, selectedIndex, visibleEntries]); + + // Focus the dialog on mount so keyboard shortcuts work immediately. + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const navigateToInputPath = useCallback(() => { + const next = pathInput.trim(); + if (!next) return; + setCurrentPath(next); + }, [pathInput]); + + const rootsList = useMemo(() => { + const items = roots.map((root) => ({ + key: root.id, + path: root.path, + icon: root.kind === "home" ? House : FolderTree, + label: + root.kind === "home" + ? `${t("folderPicker.home")} (~)` + : root.kind === "drive" + ? root.label + : root.label || root.path, + })); + + const existingPaths = new Set(items.map((item) => item.path)); + + // Docker / headless deployment common workspace + const workspaceDir = "/workspace"; + if (!existingPaths.has(workspaceDir)) { + items.push({ + key: `common:${workspaceDir}`, + path: workspaceDir, + icon: Folder, + label: "/workspace", + }); + existingPaths.add(workspaceDir); + } + + const hasInitial = items.some((item) => item.path === initialPath); + if (!hasInitial && initialPath.trim()) { + items.unshift({ + key: `initial:${initialPath}`, + path: initialPath, + icon: FolderOpen, + label: pathBaseName(initialPath) || initialPath, + }); + } + return items; + }, [initialPath, roots, t]); + + const breadcrumbs = useMemo(() => breadcrumbSegments(currentPath), [currentPath]); + const canGoUp = parentPath(currentPath) !== null; + + return createPortal( +
+ +
+ + {/* Breadcrumb + up button */} +
+ + + +
+ + {/* Body: quick locations + directory listing */} +
+ {/* Quick locations */} + + + {/* Directory listing */} +
+ {loading ? ( +
+ + {t("folderPicker.loading")} +
+ ) : error ? ( +
+ +

{error}

+
+ ) : visibleEntries.length === 0 ? ( +
+ {t("folderPicker.empty")} +
+ ) : ( +
    + {visibleEntries.map((entry, index) => { + const selected = entry.path === selectedPath; + return ( +
  • setSelectedPath(entry.path)} + onDoubleClick={() => enterDirectory(entry.path)} + onKeyDown={(event) => { + if (event.key === "Enter") enterDirectory(entry.path); + }} + // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: folder-picker rows use the standard ARIA listbox/option pattern + role="option" + aria-selected={selected} + tabIndex={-1} + > + + {entry.name} + {index === selectedIndex ? ( + + ) : null} +
  • + ); + })} +
+ )} + {truncated ? ( +

+ {t("folderPicker.truncated")} +

+ ) : null} +
+
+ + {/* Footer: path input + actions */} +
+
+ { + setPathInput(event.currentTarget.value); + setInputError(""); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + navigateToInputPath(); + } + }} + placeholder={t("folderPicker.pathPlaceholder")} + aria-label={t("folderPicker.pathPlaceholder")} + spellCheck={false} + className="h-9 w-full rounded-lg border border-black/[0.08] bg-white/70 px-3 font-mono text-xs outline-none transition-colors focus:border-primary/50 focus:ring-2 focus:ring-primary/20 dark:border-white/10 dark:bg-white/[0.05]" + /> + {inputError ?

{inputError}

: null} +
+ + +
+ + , + document.body, + ); +} + +type PendingRequest = { + resolve: (path: string | null) => void; +}; + +let activeRoot: Root | null = null; +let activeContainer: HTMLDivElement | null = null; +const pendingRequests = new Set(); + +function resolveAll(path: string | null) { + for (const request of pendingRequests) { + request.resolve(path); + } + pendingRequests.clear(); + activeRoot?.unmount(); + activeRoot = null; + activeContainer?.remove(); + activeContainer = null; +} + +/** + * Open the headless folder picker. Resolves to the chosen absolute path, or + * null when cancelled. Only one dialog can be open at a time; opening a new + * one cancels the previous. + */ +export function openFolderPicker(options: OpenFolderPickerOptions = {}): Promise { + const title = options.title?.trim() || "选择目录"; + const initialPath = options.initialPath?.trim() || "/"; + const locale = detectLocale(); + + return new Promise((resolve) => { + resolveAll(null); + + const container = document.createElement("div"); + container.setAttribute("data-folder-picker-root", "true"); + document.body.appendChild(container); + const root = createRoot(container); + activeRoot = root; + activeContainer = container; + + const request: PendingRequest = { resolve }; + pendingRequests.add(request); + + root.render( + resolveAll(path)} + />, + ); + }); +} diff --git a/crates/agent-gui/src/pages/chat/workspace/WorkspaceCloneModal.tsx b/crates/agent-gui/src/pages/chat/workspace/WorkspaceCloneModal.tsx index 69487bf9d..79679cc01 100644 --- a/crates/agent-gui/src/pages/chat/workspace/WorkspaceCloneModal.tsx +++ b/crates/agent-gui/src/pages/chat/workspace/WorkspaceCloneModal.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { FolderOpen, GitBranch, Loader2, X } from "../../../components/icons"; @@ -14,6 +13,8 @@ import { } from "../../../components/ui/select"; import { useLocale } from "../../../i18n"; import { useModalMotion } from "../../../lib/shared/modalMotion"; +import { invoke, isTauri } from "../../../lib/tauriBridge"; +import { openFolderPicker } from "./HeadlessFolderPicker"; type RemoteBranches = { defaultBranch: string; @@ -78,9 +79,18 @@ export function WorkspaceCloneModal({ async function chooseParent() { try { - const selected = await invoke("system_pick_folder", { - initial_workdir: parent || undefined, - }); + let selected: string | null; + if (isTauri()) { + selected = await invoke("system_pick_folder", { + initial_workdir: parent || undefined, + }); + } else { + // Headless: international-style folder picker dialog + selected = await openFolderPicker({ + title: "选择父目录", + initialPath: parent || "/", + }); + } const path = selected?.trim(); if (path) setParent(path); } catch (reason) { diff --git a/crates/agent-gui/src/pages/chat/workspace/cloneTasks.ts b/crates/agent-gui/src/pages/chat/workspace/cloneTasks.ts index bc27c5a21..c6a7166b6 100644 --- a/crates/agent-gui/src/pages/chat/workspace/cloneTasks.ts +++ b/crates/agent-gui/src/pages/chat/workspace/cloneTasks.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useSyncExternalStore } from "react"; +import { invoke } from "../../../lib/tauriBridge"; export type WorkspaceCloneTask = { id: string; diff --git a/crates/agent-gui/src/pages/chat/workspace/useProjectTerminals.tsx b/crates/agent-gui/src/pages/chat/workspace/useProjectTerminals.tsx index d97df09af..200617275 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useProjectTerminals.tsx +++ b/crates/agent-gui/src/pages/chat/workspace/useProjectTerminals.tsx @@ -1,8 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { type Dispatch, type SetStateAction, useCallback, useEffect, useState } from "react"; import { Terminal } from "../../../components/icons"; import type { ConfirmDialogOptions } from "../../../components/ui/confirm-dialog"; +import { invoke, listen } from "../../../lib/tauriBridge"; import { applyTerminalEventToSessions, sortTerminalSessions, diff --git a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts index 9c0ac82d7..efd2190c6 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts +++ b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts @@ -1,5 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type Dispatch, type MutableRefObject, @@ -22,6 +20,7 @@ import { sidebarScopeKey } from "../../../lib/sidebar/scope"; import type { SidebarStore } from "../../../lib/sidebar/store"; import type { SidebarScope } from "../../../lib/sidebar/types"; import { useSidebarSelector } from "../../../lib/sidebar/useSidebarSelector"; +import { invoke, isTauri, revealItemInDir } from "../../../lib/tauriBridge"; import { invokeFs } from "../../../lib/tools/fsBackend"; import { findWorkspaceProject, @@ -29,6 +28,7 @@ import { } from "../../../lib/workspaceProjects"; import { asErrorMessage } from "../chatPageUtils"; import { startWorkspaceCloneTask } from "./cloneTasks"; +import { openFolderPicker } from "./HeadlessFolderPicker"; import { createWorkspaceProjectFromPath, getDefaultWorkspaceProjectPath, @@ -349,9 +349,20 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { const handleOpenWorkspaceFolder = useCallback(async () => { try { - const picked = await invoke("system_pick_folder", { - initial_workdir: activeWorkspaceProjectPath || workdir, - }); + let picked: string | null; + if (isTauri()) { + // Desktop: native file dialog + picked = await invoke("system_pick_folder", { + initial_workdir: activeWorkspaceProjectPath || workdir, + }); + } else { + // Headless: international-style folder picker (breadcrumbs, quick + // places, directory listing) instead of a bare text input. + picked = await openFolderPicker({ + title: "选择工作空间目录", + initialPath: activeWorkspaceProjectPath || workdir || "/", + }); + } const path = picked?.trim(); if (!path) return; activateWorkspaceProject(createWorkspaceProjectFromPath(path, "managed")); diff --git a/crates/agent-gui/src/pages/mcp-hub/McpImportView.tsx b/crates/agent-gui/src/pages/mcp-hub/McpImportView.tsx index dcc460a0e..0810ddacd 100644 --- a/crates/agent-gui/src/pages/mcp-hub/McpImportView.tsx +++ b/crates/agent-gui/src/pages/mcp-hub/McpImportView.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GlassPanel } from "../../components/hub/HubChrome"; import { @@ -22,6 +21,7 @@ import { scanExternalMcpServers, scanMcpConfigFile, } from "../../lib/skills"; +import { invoke } from "../../lib/tauriBridge"; const EXTERNAL_MCP_TOOL_LABELS: Record = { "claude-code": "Claude Code", diff --git a/crates/agent-gui/src/pages/settings/AboutSection.tsx b/crates/agent-gui/src/pages/settings/AboutSection.tsx index df42fed08..eb9764fb2 100644 --- a/crates/agent-gui/src/pages/settings/AboutSection.tsx +++ b/crates/agent-gui/src/pages/settings/AboutSection.tsx @@ -1,4 +1,3 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import { AlertTriangle, CheckCircle2, @@ -15,6 +14,7 @@ import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; import type { AppUpdateCheckResult, AppUpdateController } from "../../lib/appUpdates"; import { updateUpdateSettings } from "../../lib/settings"; +import { openUrl } from "../../lib/tauriBridge"; import { formatReleaseDate } from "./aboutDate"; import { AgentActivationSwitch } from "./shared"; import type { SettingsSectionProps } from "./types"; diff --git a/crates/agent-gui/src/pages/settings/CronSection.tsx b/crates/agent-gui/src/pages/settings/CronSection.tsx index 891b51d48..13729d91a 100644 --- a/crates/agent-gui/src/pages/settings/CronSection.tsx +++ b/crates/agent-gui/src/pages/settings/CronSection.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useMemo, useState } from "react"; import { AlertTriangle, @@ -11,7 +10,6 @@ import { Terminal, Trash2, } from "../../components/icons"; - import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; import { @@ -22,6 +20,8 @@ import { } from "../../lib/automation"; import { buildModelOptions } from "../../lib/chat/page/chatPageHelpers"; import { isAgentExecutionMode, workspaceProjectPathKey } from "../../lib/settings"; +import { invoke, isTauri } from "../../lib/tauriBridge"; +import { openFolderPicker } from "../chat/workspace/HeadlessFolderPicker"; import { type CronTaskFormData, CronTaskModal } from "./CronTaskModal"; import { CronTaskViewModal } from "./CronTaskViewModal"; import { AgentActivationSwitch, ConfirmDeletePopover } from "./shared"; @@ -124,10 +124,16 @@ export function CronSection(props: SettingsSectionProps) { } async function pickWorkdirDirectory(initialWorkdir: string): Promise { - // The command is rename_all=snake_case: a camelCase key would silently - // deserialize the Option param as None (see memory: tauri-invoke-snake_case). - return await invoke("system_pick_folder", { - initial_workdir: initialWorkdir || undefined, + if (isTauri()) { + // Desktop: native file dialog + return await invoke("system_pick_folder", { + initial_workdir: initialWorkdir || undefined, + }); + } + // Headless: international-style folder picker dialog + return openFolderPicker({ + title: "选择目录", + initialPath: initialWorkdir || "/", }); } diff --git a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx index 74fd01f40..610e3d3c3 100644 --- a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import ccswitchLogoUrl from "../../../src-tauri/icons/custom/ccswitch.png"; @@ -30,7 +29,6 @@ import { X, Zap, } from "../../components/icons"; - import { Button } from "../../components/ui/button"; import { useConfirmDialog } from "../../components/ui/confirm-dialog"; import { @@ -95,6 +93,8 @@ import { } from "../../lib/settings"; import { createUuid } from "../../lib/shared/id"; import { cn } from "../../lib/shared/utils"; +import { invoke, isTauri } from "../../lib/tauriBridge"; +import { openFolderPicker } from "../chat/workspace/HeadlessFolderPicker"; import { type CherryProviderImportItem, type CherryProvidersResponse, @@ -3682,9 +3682,18 @@ export function ProvidersSection( } async function chooseCherryDataDirectory() { - const selected = await invoke("system_pick_folder", { - initial_workdir: cherryDataPath ?? cherryProviders?.dataPath ?? undefined, - }); + let selected: string | null; + if (isTauri()) { + selected = await invoke("system_pick_folder", { + initial_workdir: cherryDataPath ?? cherryProviders?.dataPath ?? undefined, + }); + } else { + // Headless: international-style folder picker dialog + selected = await openFolderPicker({ + title: "选择 Cherry Studio 数据目录", + initialPath: cherryDataPath ?? cherryProviders?.dataPath ?? "/", + }); + } if (!selected) return; setCherryLoading(true); diff --git a/crates/agent-gui/src/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index c575b88e9..a10dade74 100644 --- a/crates/agent-gui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gui/src/pages/settings/RemoteSection.tsx @@ -1,5 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Check, @@ -23,10 +21,10 @@ import { Wifi, WifiOff, } from "../../components/icons"; - import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import type { AppSettings } from "../../lib/settings"; +import { invoke, listen } from "../../lib/tauriBridge"; import { normalizeIntegerDraftInput, parseIntegerDraftValue } from "./remoteInput"; import { AgentActivationSwitch } from "./shared"; import type { SettingsSectionProps } from "./types"; diff --git a/crates/agent-gui/src/pages/settings/SshSection.tsx b/crates/agent-gui/src/pages/settings/SshSection.tsx index cba468b02..686376e3c 100644 --- a/crates/agent-gui/src/pages/settings/SshSection.tsx +++ b/crates/agent-gui/src/pages/settings/SshSection.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { type CSSProperties, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -18,7 +17,6 @@ import { Trash2, Upload, } from "../../components/icons"; - import { Button } from "../../components/ui/button"; import { useConfirmDialog } from "../../components/ui/confirm-dialog"; import { Input } from "../../components/ui/input"; @@ -39,6 +37,7 @@ import { type SshScanResult, scanSshImportCandidates, } from "../../lib/ssh/scan"; +import { invoke } from "../../lib/tauriBridge"; import type { TerminalSession } from "../../lib/terminal/types"; import { ConfirmActionPopover, PromptTag } from "./shared"; import type { SettingsSectionProps } from "./types"; diff --git a/crates/agent-gui/src/pages/settings/providerUtils.ts b/crates/agent-gui/src/pages/settings/providerUtils.ts index 8b8d70296..3d7ac2a62 100644 --- a/crates/agent-gui/src/pages/settings/providerUtils.ts +++ b/crates/agent-gui/src/pages/settings/providerUtils.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { prepareProxyRequest } from "../../lib/providers/proxy"; import { isGatewayWebuiRuntime } from "../../lib/runtimeEnv"; import { @@ -14,6 +13,7 @@ import { type UsageQueryMode, } from "../../lib/settings"; import { normalizeBaseUrl } from "../../lib/settings/normalize"; +import { invoke } from "../../lib/tauriBridge"; const GATEWAY_TOKEN_STORAGE_KEY = "liveagent.gateway.token"; const CODEX_MODELS_SUFFIXES = ["/chat/completions", "/responses", "/response"]; diff --git a/crates/agent-gui/test/chat/chat-file-links.test.mjs b/crates/agent-gui/test/chat/chat-file-links.test.mjs index 2ba3a2a57..1cc50787b 100644 --- a/crates/agent-gui/test/chat/chat-file-links.test.mjs +++ b/crates/agent-gui/test/chat/chat-file-links.test.mjs @@ -109,11 +109,11 @@ test("Gateway chat file opens run off-loop with bounded host concurrency", () => envelopeHandler.indexOf("Payload::ChatFileOpen"), envelopeHandler.indexOf("Payload::FsWriteText"), ); - assert.match(branch, /tauri::async_runtime::spawn/); + assert.match(branch, /crate::compat::async_runtime::spawn/); assert.match(branch, /let sender = self\.current_outbound_sender\(\)\?/); assert.match(branch, /send_agent_envelope_to\(sender, envelope\)/); assert.ok( - branch.indexOf("tauri::async_runtime::spawn") < + branch.indexOf("crate::compat::async_runtime::spawn") < branch.indexOf("handle_chat_file_open(request).await"), ); assert.match(chatFileLinks, /tokio::time::timeout\(CHAT_FILE_OPEN_TIMEOUT/); diff --git a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs index c58408455..595a21352 100644 --- a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs +++ b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs @@ -451,6 +451,7 @@ test("gateway tool_answer forwards validated JSON with conversation isolation", }), ); + await flushPromises(); const listener = listeners.get("gateway:chat-queue-request"); assert.ok(listener); const answers = [{ questionId: "choice", selectedLabel: "Second" }]; diff --git a/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs index 44ed7b03f..809cf49bc 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs @@ -76,6 +76,7 @@ test("gateway bridge listener keeps one worker across renders and handles native const previousDocument = globalThis.document; globalThis.window = { ...windowEvents, + __TAURI__: {}, setInterval(callback) { const id = nextTimerId++; timers.set(id, callback); @@ -170,6 +171,7 @@ test("gateway bridge listener keeps one worker across renders and handles native hookHarness.render(() => useGatewayBridgeListeners(baseParams)); + await flushPromises(); assert.ok( invokeCalls.some((call) => call.command === "gateway_chat_claim_next"), "the inbox must drain before async listen registration resolves", @@ -261,6 +263,7 @@ test("gateway bridge listener keeps one worker across renders and handles native assert.ok(runtimeWorkerIds.every((candidate) => candidate === workerId)); hookHarness.cleanup(); + await flushPromises(); const finalHeartbeat = invokeCalls .filter((call) => call.command === "gateway_chat_runtime_heartbeat") .at(-1); diff --git a/crates/agent-gui/test/chat/gateway-run-mirror-coordinator.test.mjs b/crates/agent-gui/test/chat/gateway-run-mirror-coordinator.test.mjs index 813e8afca..b4ae9f008 100644 --- a/crates/agent-gui/test/chat/gateway-run-mirror-coordinator.test.mjs +++ b/crates/agent-gui/test/chat/gateway-run-mirror-coordinator.test.mjs @@ -368,6 +368,8 @@ test("running checkpoint is an ordered barrier and cannot cross terminal", async harness.render(() => { mirror = useGatewayRunMirrorCoordinator(); }); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); mirror.registerGatewayRunMirror({ runId: "run-barrier", conversationId: "conv-barrier", diff --git a/crates/agent-gui/test/helpers/load-ts-module.mjs b/crates/agent-gui/test/helpers/load-ts-module.mjs index 86a17fc8f..2b8ce9645 100644 --- a/crates/agent-gui/test/helpers/load-ts-module.mjs +++ b/crates/agent-gui/test/helpers/load-ts-module.mjs @@ -47,6 +47,28 @@ const piAiModels = await import( ).href ); +// `import.meta` shims for modules loaded through the vm wrapper (which runs +// every module as a CommonJS function, where the meta-property is a parse +// error). `env` is empty so Vite-only guards like `import.meta.env.DEV` are +// falsy; `url` points at the loaded source file. +globalThis.__TEST_IMPORT_META_ENV__ ??= {}; +globalThis.__TEST_IMPORT_META_URL__ ??= "file:///__test__/"; + +// Persistent `window` marking the runtime as a Tauri webview. Modules loaded +// through the vm wrapper branch on `isTauri()`/`__TAURI__` (e.g. +// tauriBridge), and tests mock `@tauri-apps/api/*` — so the desktop path must +// be active even after a module finishes loading, not just during it. +globalThis.window ??= { + setTimeout, + clearTimeout, + __TAURI__: {}, + dispatchEvent() { + return true; + }, + addEventListener() {}, + removeEventListener() {}, +}; + function createDefaultMocks() { const typeboxMock = { Type: { @@ -279,7 +301,14 @@ export function createTsModuleLoader(options = {}) { } const source = fs.readFileSync(filePath, "utf8"); - const transpiled = ts.transpileModule(source, { + // The vm wrapper runs each module as a CommonJS function, where the + // `import.meta` meta-property is a parse error. Swap it for a test shim so + // Vite-only code (e.g. `import.meta.env.*`) can still be loaded and unit + // tested. The shim's `env` is empty and `url` points at the source file. + const shimmedSource = source + .replace(/\bimport\.meta\.env\b/g, "globalThis.__TEST_IMPORT_META_ENV__") + .replace(/\bimport\.meta\.url\b/g, "globalThis.__TEST_IMPORT_META_URL__"); + const transpiled = ts.transpileModule(shimmedSource, { fileName: filePath, compilerOptions: { module: ts.ModuleKind.CommonJS, @@ -322,6 +351,11 @@ export function createTsModuleLoader(options = {}) { globalThis.window = { setTimeout, clearTimeout, + // Mark the runtime as a Tauri webview so modules that branch on + // `isTauri()`/`__TAURI__` (e.g. tauriBridge) take the desktop path, + // where tests mock `@tauri-apps/api/*` instead of hitting the + // headless HTTP/WebSocket transport. + __TAURI__: {}, }; } diff --git a/crates/agent-gui/test/settings/global-shortcuts.test.mjs b/crates/agent-gui/test/settings/global-shortcuts.test.mjs index 7a55ad4d8..96e7e8aff 100644 --- a/crates/agent-gui/test/settings/global-shortcuts.test.mjs +++ b/crates/agent-gui/test/settings/global-shortcuts.test.mjs @@ -25,7 +25,7 @@ function createMemoryLocalStorage(initial = {}) { async function withWindow(localStorage, task) { const previousWindow = globalThis.window; - globalThis.window = { localStorage }; + globalThis.window = { __TAURI__: {}, localStorage }; try { return await task(); } finally { diff --git a/crates/agent-gui/test/settings/provider-models-fetch.test.mjs b/crates/agent-gui/test/settings/provider-models-fetch.test.mjs index 7129f3e1f..de3d61607 100644 --- a/crates/agent-gui/test/settings/provider-models-fetch.test.mjs +++ b/crates/agent-gui/test/settings/provider-models-fetch.test.mjs @@ -333,6 +333,7 @@ test("gateway WebUI forwards the system proxy choice to desktop model fetching", const previousWindow = globalThis.window; globalThis.document = { documentElement: { dataset: { liveagentWebui: "gateway" } } }; globalThis.window = { + __TAURI__: {}, localStorage: { getItem(key) { return key === "liveagent.gateway.token" ? "gateway-token" : null; diff --git a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs index 0e81a97ed..31b43215f 100644 --- a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs +++ b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs @@ -2063,6 +2063,7 @@ test("SkillsManager management can auto-enable installed Skills without exposing }); const previousWindow = globalThis.window; globalThis.window = { + __TAURI__: {}, dispatchEvent(event) { events.push(event.type); }, @@ -2288,6 +2289,7 @@ test("SkillsManager create action builds payload and refreshes skill discovery", }); const previousWindow = globalThis.window; globalThis.window = { + __TAURI__: {}, dispatchEvent(event) { events.push(event.type); }, diff --git a/docker/.env b/docker/.env new file mode 100644 index 000000000..c5229997c --- /dev/null +++ b/docker/.env @@ -0,0 +1,24 @@ +# LiveAgent Docker Compose 环境变量(可选) +# ========================================= +# 三个独立配置文件共享此文件(放在与 compose 文件同目录,或运行 compose 的目录)。 +# 不设置时各 compose 文件内已有默认值,可直接使用。 +# +# 用法示例(在 LiveAgent 项目根目录): +# docker compose -f docker/docker-compose.gateway.yml up -d +# docker compose -f docker/docker-compose.headless-core.yml up -d +# docker compose -f docker/docker-compose.headless-full.yml up -d + +# GHCR 镜像仓库所有者(fork 后改为自己的 GitHub 用户名) +GHCR_OWNER=thirsty5034 + +# 镜像标签(main 分支 push 自动构建为 main;打 v* tag 后可填 v1.2.3 / latest) +TAG=main + +# 项目代码挂载路径(宿主机) +PROJECTS_DIR=/vol1/1000/projects + +# 数据持久化目录(相对运行 compose 的目录) +DATA_DIR=./data + +# 时区 +TZ=Asia/Shanghai \ No newline at end of file diff --git a/docker/docker-compose.gateway.yml b/docker/docker-compose.gateway.yml new file mode 100644 index 000000000..0261dd075 --- /dev/null +++ b/docker/docker-compose.gateway.yml @@ -0,0 +1,27 @@ +# LiveAgent Gateway — Docker Compose(独立配置,可直接使用) +# ========================================================== +# 轻量网关服务,单二进制,无开发工具链(~100 MB)。 +# 镜像由 GitHub Actions 构建推送,此处直接拉取,无需本地构建。 +# +# 用法(在 LiveAgent 项目根目录): +# docker compose -f docker/docker-compose.gateway.yml up -d +# docker compose -f docker/docker-compose.gateway.yml logs -f +# docker compose -f docker/docker-compose.gateway.yml down +# +# 环境变量(可选,不设置时用默认值): +# GHCR_OWNER=thirsty5034 镜像仓库所有者(fork 后改为自己的用户名) +# TAG=main 镜像版本(main 分支 push 自动构建为 main;打 v* tag 后可填 v1.2.3 / latest) + +services: + gateway: + image: ghcr.io/${GHCR_OWNER:-thirsty5034}/liveagent-gateway:${TAG:-main} + container_name: liveagent-gateway + network_mode: host # 直接使用宿主机网络,无 NAT 转发 + environment: + TZ: Asia/Shanghai + PORT: 8080 + LIVEAGENT_GATEWAY_DATA_DIR: /var/lib/liveagent + volumes: + - /vol1/1000/projects:/workspace # 项目代码统一挂载 + - ./data:/var/lib/liveagent # 数据持久化(相对运行 compose 的目录) + restart: unless-stopped \ No newline at end of file diff --git a/docker/docker-compose.headless-core.yml b/docker/docker-compose.headless-core.yml new file mode 100644 index 000000000..02b845acf --- /dev/null +++ b/docker/docker-compose.headless-core.yml @@ -0,0 +1,52 @@ +# LiveAgent Headless Core — Docker Compose(独立配置,可直接使用) +# =============================================================== +# 核心开发沙箱(~0.9 GB)。 +# 预装: git + 编译链 + go 1.25.12 + node 22.19.0 + python 3.12 + pnpm + bun +# 镜像由 GitHub Actions 构建推送,此处直接拉取,无需本地构建。 +# +# 用法(在 LiveAgent 项目根目录): +# docker compose -f docker/docker-compose.headless-core.yml up -d +# docker compose -f docker/docker-compose.headless-core.yml logs -f +# docker compose -f docker/docker-compose.headless-core.yml down +# +# 环境变量(可选,不设置时用默认值): +# GHCR_OWNER=thirsty5034 镜像仓库所有者(fork 后改为自己的用户名) +# TAG=main 镜像版本(main 分支 push 自动构建为 main;打 v* tag 后可填 v1.2.3 / latest) +# +# 切换运行时版本: 取消注释下方 environment 并改版本号,缺失版本首次启动自动补装 +# (补装内容写入 mise-data 卷,容器重建保留,只付一次下载)。 +# MISE_NODE_VERSION: 20.18.0 +# MISE_PYTHON_VERSION: 3.11 +# MISE_GO_VERSION: 1.24 +# +# 数据持久化: +# ./data:/var/lib/liveagent → liveagent 用户 home(含 ~/.liveagent 应用数据) +# mise-data:/opt/mise → mise 数据目录(named volume) +# 注意: 不能用 ./mise-data 这类 bind mount —— 空目录会把镜像内预装工具链 +# 遮蔽掉,导致启动时重新下载全部运行时。named volume 首次挂载时自动从镜像 +# 复制 /opt/mise 内容(含预装版本与属主),之后补装增量写卷,零初始化。 +# 备份: docker run --rm -v liveagent-core-mise-data:/data -v $(pwd):/b \ +# alpine tar czf /b/data/mise-data-backup.tar.gz -C /data . + +services: + headless-core: + image: ghcr.io/${GHCR_OWNER:-thirsty5034}/liveagent-core:${TAG:-main} + container_name: liveagent-core + network_mode: host # 直接使用宿主机网络,无 NAT 转发 + environment: + TZ: Asia/Shanghai + LIVEAGENT_HEADLESS_HOST: 0.0.0.0 + LIVEAGENT_HEADLESS_PORT: 17890 + LIVEAGENT_DATA_DIR: /var/lib/liveagent + LANG: en_US.UTF-8 + volumes: + - /vol1/1000/projects:/workspace # 项目代码统一挂载 + - ./data:/var/lib/liveagent # 数据持久化(相对运行 compose 的目录) + - mise-data:/opt/mise # mise 数据卷(首次自动从镜像复制预装工具链) + restart: unless-stopped + +volumes: + # named volume:首次挂载自动从镜像复制 /opt/mise(含预装版本,属主 liveagent), + # 懒加载补装的版本持久化于此。与 full 镜像的卷分开命名,避免预装内容互相污染。 + mise-data: + name: liveagent-core-mise-data \ No newline at end of file diff --git a/docker/docker-compose.headless-full.yml b/docker/docker-compose.headless-full.yml new file mode 100644 index 000000000..c56818e60 --- /dev/null +++ b/docker/docker-compose.headless-full.yml @@ -0,0 +1,54 @@ +# LiveAgent Headless Full — Docker Compose(独立配置,可直接使用) +# =============================================================== +# 全栈开发沙箱(~1.2 GB)。 +# 预装: core 全部工具 + java temurin-17 + maven 3.9 +# (Java 8 未预装,首次使用时自动补装) +# 镜像由 GitHub Actions 构建推送,此处直接拉取,无需本地构建。 +# +# 用法(在 LiveAgent 项目根目录): +# docker compose -f docker/docker-compose.headless-full.yml up -d +# docker compose -f docker/docker-compose.headless-full.yml logs -f +# docker compose -f docker/docker-compose.headless-full.yml down +# +# 环境变量(可选,不设置时用默认值): +# GHCR_OWNER=thirsty5034 镜像仓库所有者(fork 后改为自己的用户名) +# TAG=main 镜像版本(main 分支 push 自动构建为 main;打 v* tag 后可填 v1.2.3 / latest) +# +# 切换运行时版本: 取消注释下方 environment 并改版本号,缺失版本首次启动自动补装 +# (补装内容写入 mise-data 卷,容器重建保留,只付一次下载)。 +# MISE_NODE_VERSION: 20.18.0 +# MISE_PYTHON_VERSION: 3.11 +# MISE_GO_VERSION: 1.24 +# MISE_JAVA_VERSION: temurin-8 +# +# 数据持久化: +# ./data:/var/lib/liveagent → liveagent 用户 home(含 ~/.liveagent 应用数据) +# mise-data:/opt/mise → mise 数据目录(named volume) +# 注意: 不能用 ./mise-data 这类 bind mount —— 空目录会把镜像内预装工具链 +# 遮蔽掉,导致启动时重新下载全部运行时。named volume 首次挂载时自动从镜像 +# 复制 /opt/mise 内容(含预装版本与属主),之后补装增量写卷,零初始化。 +# 备份: docker run --rm -v liveagent-full-mise-data:/data -v $(pwd):/b \ +# alpine tar czf /b/data/mise-data-backup.tar.gz -C /data . + +services: + headless-full: + image: ghcr.io/${GHCR_OWNER:-thirsty5034}/liveagent-full:${TAG:-main} + container_name: liveagent-full + network_mode: host # 直接使用宿主机网络,无 NAT 转发 + environment: + TZ: Asia/Shanghai + LIVEAGENT_HEADLESS_HOST: 0.0.0.0 + LIVEAGENT_HEADLESS_PORT: 17890 + LIVEAGENT_DATA_DIR: /var/lib/liveagent + LANG: en_US.UTF-8 + volumes: + - /vol1/1000/projects:/workspace # 项目代码统一挂载 + - ./data:/var/lib/liveagent # 数据持久化(相对运行 compose 的目录) + - mise-data:/opt/mise # mise 数据卷(首次自动从镜像复制预装工具链) + restart: unless-stopped + +volumes: + # named volume:首次挂载自动从镜像复制 /opt/mise(含预装版本,属主 liveagent), + # 懒加载补装的版本持久化于此。与 core 镜像的卷分开命名,避免预装内容互相污染。 + mise-data: + name: liveagent-full-mise-data \ No newline at end of file diff --git a/docker/docker-compose.headless-minimal.yml b/docker/docker-compose.headless-minimal.yml new file mode 100644 index 000000000..be85ad6e3 --- /dev/null +++ b/docker/docker-compose.headless-minimal.yml @@ -0,0 +1,36 @@ +# LiveAgent Headless Minimal — Docker Compose(独立配置,可直接使用) +# =============================================================== +# 最小生产镜像(~0.7 GB):仅基础工具 + liveagent 二进制,无任何语言运行时。 +# 镜像由 GitHub Actions 构建推送,此处直接拉取,无需本地构建。 +# +# 用法(在 LiveAgent 项目根目录): +# docker compose -f docker/docker-compose.headless-minimal.yml up -d +# docker compose -f docker/docker-compose.headless-minimal.yml logs -f +# docker compose -f docker/docker-compose.headless-minimal.yml down +# +# 环境变量(可选,不设置时用默认值): +# GHCR_OWNER=thirsty5034 镜像仓库所有者(fork 后改为自己的用户名) +# TAG=main 镜像版本(main 分支 push 自动构建为 main;打 v* tag 后可填 v1.2.3 / latest) +# +# 数据持久化: +# ./data:/var/lib/liveagent → liveagent 用户 home(含 ~/.liveagent 应用数据) +# (minimal 无 mise,不需要 /opt/mise 数据卷) +# +# 需要语言运行时?换用 headless-core / headless-full 镜像: +# docker compose -f docker/docker-compose.headless-core.yml up -d + +services: + headless-minimal: + image: ghcr.io/${GHCR_OWNER:-thirsty5034}/liveagent-minimal:${TAG:-main} + container_name: liveagent-minimal + network_mode: host # 直接使用宿主机网络,无 NAT 转发 + environment: + TZ: Asia/Shanghai + LIVEAGENT_HEADLESS_HOST: 0.0.0.0 + LIVEAGENT_HEADLESS_PORT: 17890 + LIVEAGENT_DATA_DIR: /var/lib/liveagent + LANG: en_US.UTF-8 + volumes: + - /vol1/1000/projects:/workspace # 项目代码统一挂载 + - ./data:/var/lib/liveagent # 数据持久化(相对运行 compose 的目录) + restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000..98e4295f1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# LiveAgent headless 启动入口 +# +# 1) mise 自举:确保全局配置里声明的工具版本都已安装。 +# - 镜像预装的版本(core/full)已存在,这里秒过; +# - 用户通过环境变量(如 MISE_JAVA_VERSION=temurin-8)切换到的 +# 缺失版本会在此自动补装(懒加载,需出网,补装内容持久化在 +# MISE_DATA_DIR 卷上,只付一次下载)。 +# 2) 注入 mise 环境(PATH / JAVA_HOME / MISE_* 等),再启动主进程, +# 使 docker exec 进入的 shell 直接具备完整工具链。 +set -euo pipefail + +if command -v mise >/dev/null 2>&1; then + # 懒加载补装(需出网);最多等 300s,避免离线/慢网拖死启动。 + if command -v timeout >/dev/null 2>&1; then + timeout 300 mise install -y >/dev/null 2>&1 \ + || echo "[entrypoint] warning: mise install failed/timed out, continuing with preinstalled tools" >&2 + else + mise install -y >/dev/null 2>&1 \ + || echo "[entrypoint] warning: mise install failed, continuing with preinstalled tools" >&2 + fi + eval "$(mise env --shell bash)" >/dev/null 2>&1 || true +fi + +exec /usr/local/bin/liveagent "$@" diff --git a/docker/mise.core.toml b/docker/mise.core.toml new file mode 100644 index 000000000..92d2db59e --- /dev/null +++ b/docker/mise.core.toml @@ -0,0 +1,9 @@ +# core 镜像全局默认工具链(mise 管理)。 +# 用户可在 compose 中用环境变量 MISE__VERSION 覆盖任意一项, +# 或挂载自己的 mise.toml / .tool-versions(见 README "开发工具镜像" 一节)。 +[tools] +go = "1.25.12" +node = "22.19.0" +"npm:pnpm" = "10.32.1" +"npm:bun" = "1.3.14" +python = "3.12" diff --git a/docker/mise.full.toml b/docker/mise.full.toml new file mode 100644 index 000000000..a2f760bcc --- /dev/null +++ b/docker/mise.full.toml @@ -0,0 +1,12 @@ +# full 镜像全局默认工具链(mise 管理)= core + Java。 +# Java 默认 temurin-17;需要 Java 8 的用户只需设置 +# MISE_JAVA_VERSION: "temurin-8" +# 首次启动会自动补装(懒加载),补装的版本持久化在 /opt/mise 卷上。 +[tools] +go = "1.25.12" +node = "22.19.0" +"npm:pnpm" = "10.32.1" +"npm:bun" = "1.3.14" +python = "3.12" +java = "temurin-17" +maven = "3.9" diff --git a/docs/PR-feat-headless-pr-g.md b/docs/PR-feat-headless-pr-g.md new file mode 100644 index 000000000..940170882 --- /dev/null +++ b/docs/PR-feat-headless-pr-g.md @@ -0,0 +1,149 @@ +# PR Title + +``` +feat(headless): headless server runtime, manifest-driven command adapters, security hardening, and dev-tools Docker images +``` + +--- + +# PR Body + +## Summary + +Adds a full **headless runtime** for LiveAgent: the same business command +surface the desktop build exposes via `#[tauri::command]` now also runs as a +standalone axum HTTP/WebSocket server (`--no-default-features`, no Tauri), +serving the existing WebUI through an in-page bridge — plus a reproducible +command-registry/generator pipeline, a same-origin security model, and +layered dev-tools Docker images (`core`/`full`) managed with `mise`. + +**32 commits, 153 files changed (+14,775 / −1,701).** + +## Why + +- Let the WebUI run in a container/server without the desktop Tauri runtime + (browser mode, remote deployment, dev sandboxes). +- Decouple the business layer from Tauri so the same command surface is + reusable across runtimes. +- Make the 234-command adapter layer **generated and verified, not + hand-synced** (the historical drift failure mode of the headless build). +- Close the CORS/WS/rate-limit holes in the initial headless server. + +## Highlights + +### 1. Decouple business layer from Tauri (P1.1) +- `refactor: decouple event emission from tauri AppHandle (P1.1 PR-A)` +- `refactor: decouple tauri State/command macro from business layer (P1.1 PR-B)` +- `refactor: extract AppContext assembly, gate desktop-only modules (P1.1 PR-C)` + +### 2. Headless runtime (P1.2) +- `build: feature-gate Tauri deps, headless build strips Tauri (P1.2 PR-D)` +- `feat: add headless binary with axum server and command dispatch (P1.2 PR-E)` +- `feat: add same-interface tauriBridge so WebUI can run headless (P1.2 PR-F)` +- `ci: add headless-rust job guarding the no-default-features build (P1.2 PR-G)` +- `feat: serve WebUI statics with SPA fallback and same-origin base URL (P1.2 PR-H)` +- Follow-up fixes: workspace picker, loopback rate-limit exemption, route + fixes, `/proc` process-group liveness probe for the runtime bridge. + +Routes: `GET /health`, `GET /api/status`, `POST /api/invoke`, +`GET /ws` (event broadcast), `GET /*` (WebUI SPA fallback), +`/proxy/{provider}/` BFF routes (page-origin base URL). + +### 3. Command registry & generator (reproducibility) +- `scripts/manifest/commands.json` — **committed source of truth** for the + 234 Tauri commands (replaces the old un-reproducible `/tmp` snapshot flow). +- `scripts/build_type_map.py` — derives the Rust type map from `src/*.rs`. +- `scripts/gen_adapters.py` — regenerates `src/commands/adapters.rs` + (desktop-only thin adapters re-attaching `#[tauri::command]`). +- `scripts/gen_headless.sh` — one-shot pipeline (`build_type_map` → + `gen_adapters`), wired into CI `gen-verify` job with `git diff --exit-code`. +- `scripts/verify_headless.py` — asserts `headless.rs` dispatch arms match the + manifest **both ways** (missing + extra). Currently 234 = 234. +- `scripts/extract_cmds.py` / `gen_headless.py` marked `[HISTORICAL]`. +- New-command flow documented in README (5 steps). + +### 4. Security hardening +- **Same-origin gate** replaces permissive `CorsLayer(Any)`: requests with an + `Origin` that is neither same-origin nor `LIVEAGENT_HEADLESS_CORS_ORIGINS` + are 403'd before routing; OPTIONS preflight returns proper CORS headers. +- **`/api/invoke` token auth** (`LIVEAGENT_API_TOKEN`) with same-origin + exemption for the WebUI; non-browser callers must send `Authorization: Bearer`. +- **`/ws` origin check**: browser (same-origin) connections pass; + non-browser clients must send `?token=` when a token is configured. +- **Rate-limit IP** now uses the real TCP peer (`ConnectInfo`); + `X-Forwarded-For` is only trusted with `LIVEAGENT_TRUST_PROXY_HEADERS=1`. +- Warn at startup when bound to a non-loopback interface without a token. +- Fix pre-existing `runtime-fallback` build bug (`serve_static_path` was not + `async` though it awaits). + +### 5. Dev-tools Docker images (core / full) +- Layered images with `mise`-managed runtimes (`docker/mise.core.toml`, + `docker/mise.full.toml`), lazy-loading, and a `timeout`-bounded `mise + install` in `entrypoint.sh`. +- Toolchain reachability fix: `/etc/profile.d/mise.sh` injects the full mise + env into **login shells** (covers the app's `bash -lc` exec path); + `bash.bashrc` keeps covering interactive shells; PATH fallback via + `/opt/mise/shims` for non-shell processes. +- `bun 1.3.14` installed via the **npm backend** (npmmirror) — the mise core + backend hardcodes GitHub releases, which is unreachable in restricted + networks; injection layers and npm-registry routing documented in README. +- Consolidated the headless image workflow; removed the old single-image + workflow (`liveagent-docker.yml` + `Dockerfile.headless`). + +### 6. CI build chain +- Rust bumped through 1.85 → 1.88 → 1.90 → 1.97 for `libsqlite3-sys` + cfg_select / `lopdf` / `zip` / `time` / `base16ct` compat. +- `libclang-dev` (rquickjs-sys bindgen) and `protobuf-compiler` (gateway + proto) installed in the headless image build. +- Cargo cache isolated per arch to stop parallel buildx races. +- WebUI `dist` embedded in the headless image; server binds `0.0.0.0`. +- LiveAgent home pointed at the data volume so the history DB is writable. + +### 7. WebUI +- `HeadlessFolderPicker` for workspace directory selection (quick locations + simplified to `/workspace` for the headless deployment). + +## Verification + +- `scripts/verify_headless.py`: **234 manifest commands = 234 dispatch arms** + (missing + extra, both ways) — `OK`. +- `cargo test --no-default-features --lib`: **657 passed; 0 failed** (current + HEAD `07bfc20d`). +- `cargo check --no-default-features` (embedded) and + `cargo check --no-default-features --features runtime-fallback`: pass. +- Generator re-entrancy: `gen_headless.sh` → `git diff --exit-code + adapters.rs` → `verify_headless.py` (234 = 234). +- Live behavior matrix (auth off/on): same-origin 200, cross-origin 403, + preflight 204 + CORS headers, Bearer auth, WS `?token=` auth, cross-origin + WS 403 — all as designed. +- Release-binary smoke test on the headless server: pass. +- Images: `core`/`full` built and published to GHCR; container verified with + full toolchain visible under `bash -lc`, WebUI HTTP 200, API/WS working. + +## Compatibility + +- Default `desktop` build is unaffected — Tauri deps stay feature-gated; + `adapters.rs` and `headless.rs` are mutually exclusive by feature. +- The generated adapter layer preserves the exact pre-refactor command names. + +## Notes for reviewers + +- Branch has been **rebased onto the latest `upstream/main` (`00a2c6fc`)**; + merge-tree probe shows **0 conflicts** with `upstream/main`. + - One conflict was resolved during rebase: `ProvidersSection.tsx` import + block — this branch's `openFolderPicker` import was kept, while the + `ProviderIdentityDrawer` import (and its UI) was dropped to align with + upstream's removal of the built-in CLI identity feature (commit + `0f95b836` etc.). No other files conflicted. +- WebSocket token auth is query-param only (`?token=`) by design: browser + `WebSocket` cannot set custom headers. +- The `headless.rs` dispatch block is hand-maintained and *verified* (not + regenerated) — the generator only produces `adapters.rs`. + +## PR Status + +- **PR**: [#379](https://github.com/Stack-Cairn/LiveAgent/pull/379) +- **Issue**: [#380](https://github.com/Stack-Cairn/LiveAgent/issues/380) +- **State**: Open — awaiting human review +- **Governance**: ✅ Passed (linked issue + screenshots) +- **Mergeable**: True (no conflicts) diff --git a/docs/screenshots/headless-webui.png b/docs/screenshots/headless-webui.png new file mode 100644 index 000000000..a84743bd9 Binary files /dev/null and b/docs/screenshots/headless-webui.png differ diff --git a/docs/screenshots/liveagent-desktop-ui.png b/docs/screenshots/liveagent-desktop-ui.png new file mode 100644 index 000000000..813e50440 Binary files /dev/null and b/docs/screenshots/liveagent-desktop-ui.png differ diff --git a/scripts/build_type_map.py b/scripts/build_type_map.py new file mode 100644 index 000000000..c525c5a8c --- /dev/null +++ b/scripts/build_type_map.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Build type-name -> file-path mapping from src/*.rs pub type/enum/struct defs. + +Usage: + python3 scripts/build_type_map.py [--src ] [--out ] +""" +import os, re, json, argparse + +REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +DEFAULT_SRC = os.path.join(REPO, "crates/agent-gui/src-tauri/src") +DEFAULT_OUT = os.path.join(REPO, "crates/agent-gui/src-tauri/target/gen-meta/type_map.json") + +ap = argparse.ArgumentParser() +ap.add_argument("--src", default=DEFAULT_SRC, help="src directory") +ap.add_argument("--out", default=DEFAULT_OUT, help="output type_map.json path") +args = ap.parse_args() + +SRC = args.src +mapping = {} +for dirpath, _dn, filenames in os.walk(SRC): + for fn in filenames: + if not fn.endswith('.rs'): + continue + p = os.path.join(dirpath, fn) + with open(p) as f: + content = f.read() + for m in re.finditer(r'^pub(?:\(crate\))? (struct|enum|type)\s+([A-Za-z0-9_]+)', content, re.M): + name = m.group(2) + rel = os.path.relpath(p, SRC).replace(os.sep, '/') + if name in mapping and mapping[name] != rel: + print(f"DUP: {name}: {mapping[name]} vs {rel}") + mapping[name] = rel +os.makedirs(os.path.dirname(args.out), exist_ok=True) +json.dump(mapping, open(args.out, 'w'), indent=1) +print(f"total types mapped: {len(mapping)} -> {args.out}") diff --git a/scripts/extract_cmds.py b/scripts/extract_cmds.py new file mode 100644 index 000000000..7eea6a655 --- /dev/null +++ b/scripts/extract_cmds.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""[HISTORICAL] Extract #[tauri::command] signatures. + +NOTE: After the P1.1 refactor the business fns in src/commands no longer carry +#[tauri::command] attributes, so this extractor can no longer rebuild the +command table from source. The authoritative command table is committed at +scripts/manifest/commands.json; this script is kept for reference and for +rebuilding that manifest when business fns carry the attribute again. + +Outputs JSON: [{attr, file, module, name, is_async, params:[{name, type}], ret, line}] + +Usage: + python3 scripts/extract_cmds.py [--src ] [--out ] +""" +import os, re, json, sys, argparse + +REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +DEFAULT_SRC = os.path.join(REPO, "crates/agent-gui/src-tauri/src/commands") +DEFAULT_OUT = os.path.join(REPO, "crates/agent-gui/src-tauri/target/gen-meta/commands.json") + +ap = argparse.ArgumentParser() +ap.add_argument("--src", default=DEFAULT_SRC, help="src/commands directory") +ap.add_argument("--out", default=DEFAULT_OUT, help="output commands.json path") +args = ap.parse_args() + +ROOT = args.src +MODULE_MAP = { + "app/app.rs": "app", "app/system.rs": "system", "app/tray.rs": "tray", + "app/update.rs": "update", + "automation/cron.rs": "cron", "automation/hook.rs": "hook", + "config/settings/commands.rs": "settings", + "config/settings/ccs_import.rs": "settings", + "config/settings/cherry_import.rs": "settings", + "history/chat_history/branch.rs": "chat_history", + "history/chat_history/commands.rs": "chat_history", + "history/chat_history/delete.rs": "chat_history", + "history/chat_history/replace.rs": "chat_history", + "history/history_db.rs": "history_db", + "history/subagent_store.rs": "subagent_store", + "integration/gateway.rs": "gateway", "integration/mcp.rs": "mcp", + "integration/memory.rs": "memory", + "runtime/process.rs": "process", "runtime/sftp.rs": "sftp", + "runtime/shell.rs": "shell", "runtime/terminal.rs": "terminal", + "workspace/chat_file_links.rs": "chat_file_links", "workspace/fs.rs": "fs", + "workspace/git.rs": "git", "workspace/subagent_worktree.rs": "subagent_worktree", +} + +ATTR_RE = re.compile(r'#\[tauri::command(\\([^)]*\\))?\]') +FN_RE = re.compile(r'pub (async )?fn ([a-z0-9_]+)\s*\(') + +EXTRA = [(os.path.join(REPO, "crates/agent-gui/src-tauri/src/services/proxy.rs"), "services::proxy")] + +def extract_sig(lines, fn_line): + """Return (sig_text_from_open_paren, end_line_idx, end_pos) or None.""" + text = lines[fn_line] + start = text.find('(') + if start == -1: + return None + depth = 0 + line_idx = fn_line + pos = start + while True: + if line_idx >= len(lines): + return None + line = lines[line_idx] + while pos < len(line): + ch = line[pos] + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0: + return line_idx, pos + pos += 1 + line_idx += 1 + pos = 0 + +def split_params(s): + parts, d, cur = [], 0, [] + for ch in s: + if ch == '<': + d += 1 + elif ch == '>': + d -= 1 + if ch == ',' and d == 0: + parts.append(''.join(cur).strip()); cur = [] + else: + cur.append(ch) + if cur: + parts.append(''.join(cur).strip()) + return parts + +def find_commands(path): + with open(path) as f: + lines = f.readlines() + cmds = [] + i = 0 + while i < len(lines): + m = ATTR_RE.search(lines[i]) + if m: + attr = lines[i].strip() + j = i + 1 + while j < len(lines) and (not lines[j].strip() or lines[j].strip().startswith('//')): + j += 1 + fm = FN_RE.search(lines[j]) if j < len(lines) else None + if not fm: + print(f"WARN: no fn after attr at {path}:{i+1}", file=sys.stderr) + i += 1 + continue + is_async = bool(fm.group(1)) + name = fm.group(2) + res = extract_sig(lines, j) + if res is None: + print(f"WARN: unbalanced params at {path}:{j+1} name={name}", file=sys.stderr) + i += 1 + continue + end_line, end_pos = res + # params text between '(' and matching ')' + params_text = '' + for li in range(j, end_line + 1): + line = lines[li] + s = line.find('(') if li == j else 0 + e = end_pos if li == end_line else len(line) + params_text += line[s:e] + params_text = params_text[1:] # drop leading '(' + # ret: read after close paren until '{' + rest = '' + li = end_line + p = end_pos + 1 + while li < len(lines): + seg = lines[li][p:] if li == end_line else lines[li] + rest += seg + if '{' in seg: + break + li += 1 + p = 0 + ret = None + rm = re.search(r'->\s*(.+)$', rest, re.S) + if rm: + ret = rm.group(1).rstrip() + ret = ret.split('{')[0].strip().rstrip(',') + params = [] + if params_text.strip(): + for p in split_params(params_text): + p = p.strip() + if not p: + continue + mm = re.match(r'([a-z_][a-z0-9_]*)\s*:\s*(.+)', p, re.S) + if mm: + params.append({"name": mm.group(1).strip(), "type": mm.group(2).strip()}) + else: + print(f"WARN: unparsed param '{p[:80]}' in {path}:{j+1} {name}", file=sys.stderr) + cmds.append({ + "attr": attr, + "file": os.path.relpath(path, ROOT), + "module": MODULE_MAP.get(os.path.relpath(path, ROOT).replace("src/", ""), "?"), + "name": name, + "is_async": is_async, + "params": params, + "ret": ret, + "line": j + 1, + }) + i = end_line + 1 + continue + i += 1 + return cmds + +def collect_commands(root): + """Return the command table (list of dicts) for a given src/commands dir.""" + all_cmds = [] + for dirpath, _dn, filenames in os.walk(root): + for fn in filenames: + if fn.endswith(".rs"): + p = os.path.join(dirpath, fn) + rel = os.path.relpath(p, root) + if rel in MODULE_MAP: + all_cmds.extend(find_commands(p)) + for p, _mod in EXTRA: + if os.path.exists(p): + cmds = find_commands(p) + for c in cmds: + c["module"] = "proxy" + c["is_service"] = True + all_cmds.extend(cmds) + return all_cmds + +def main_silent(root=None): + return collect_commands(root or ROOT) + +def main(): + all_cmds = collect_commands(ROOT) + os.makedirs(os.path.dirname(args.out), exist_ok=True) + json.dump(all_cmds, open(args.out, "w"), indent=1) + print(f"total: {len(all_cmds)} -> {args.out}") + mods = {} + for c in all_cmds: + mods[c["module"]] = mods.get(c["module"], 0) + 1 + for m, n in sorted(mods.items()): + print(f" {m}: {n}") + +if __name__ == "__main__": + main() diff --git a/scripts/gen_adapters.py b/scripts/gen_adapters.py new file mode 100644 index 000000000..cf5f13c1f --- /dev/null +++ b/scripts/gen_adapters.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Generate src/commands/adapters.rs: tauri command adapters for all 234 business fns. + +Path resolution rules: +- commands/ files map to flattened crate::commands:: paths +- include!() flattened dirs (settings, chat_history) -> parent module +- private `mod types;` + `pub use types::*` -> parent module +- everything else keeps its file-tree path +""" +import json, re, os, argparse + +REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +META = os.path.join(REPO, "crates/agent-gui/src-tauri/target/gen-meta") +ap = argparse.ArgumentParser() +ap.add_argument('--commands', default=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'manifest', 'commands.json')) +ap.add_argument('--types', default=os.path.join(META, 'type_map.json')) +ap.add_argument('--out', default=os.path.join(REPO, 'crates/agent-gui/src-tauri/src/commands/adapters.rs')) +args = ap.parse_args() + +cmds = json.load(open(args.commands)) +type_map = json.load(open(args.types)) + +KNOWN_PATH_PREFIX = ('tauri::', 'crate::', 'super::', 'self::', 'std::') + +# ---- explicit file -> module path (commands/ flattened by re-exports) ---- +FLAT_FILE = { + 'commands/app/app.rs': 'commands::app', + 'commands/app/system.rs': 'commands::system', + 'commands/app/tray.rs': 'commands::tray', + 'commands/app/update.rs': 'commands::update', + 'commands/automation/cron.rs': 'commands::cron', + 'commands/automation/hook.rs': 'commands::hook', + 'commands/history/history_db.rs': 'commands::history_db', + 'commands/history/subagent_store.rs': 'commands::subagent_store', + 'commands/integration/gateway.rs': 'commands::gateway', + 'commands/integration/mcp.rs': 'commands::mcp', + 'commands/integration/memory.rs': 'commands::memory', + 'commands/runtime/process.rs': 'commands::process', + 'commands/runtime/sftp.rs': 'commands::sftp', + 'commands/runtime/shell.rs': 'commands::shell', + 'commands/runtime/terminal.rs': 'commands::terminal', + 'commands/workspace/chat_file_links.rs': 'commands::chat_file_links', + 'commands/workspace/fs.rs': 'commands::fs', + 'commands/workspace/git.rs': 'commands::git', + 'commands/workspace/subagent_worktree.rs': 'commands::subagent_worktree', +} +# include!() flattened directories +FLAT_PREFIX = [ + ('commands/config/settings/', 'commands::settings'), + ('commands/history/chat_history/', 'commands::chat_history'), + ('services/memory/', 'services::memory'), + ('runtime/terminal/', 'runtime::terminal'), +] +# private `mod types;` + `pub use types::*` -> parent module +FLAT_TYPES = { + 'services/gateway/types.rs': 'services::gateway', + 'services/skills/types.rs': 'services::skills', + 'services/automation/types.rs': 'services::automation', + 'runtime/terminal/types.rs': 'runtime::terminal', +} + +def resolve_use_path(rel): + if rel in FLAT_FILE: + return FLAT_FILE[rel] + if rel in FLAT_TYPES: + return FLAT_TYPES[rel] + for prefix, flat in FLAT_PREFIX: + if rel.startswith(prefix): + return flat + # default: file-tree path (foo/mod.rs -> foo) + if rel.endswith('/mod.rs'): + return rel[:-7].replace('/', '::') + return rel[:-3].replace('/', '::') + +# std imports needed (non-prelude) +STD_TYPE_IMPORTS = { + 'HashMap': 'std::collections::HashMap', + 'HashSet': 'std::collections::HashSet', + 'BTreeMap': 'std::collections::BTreeMap', + 'BTreeSet': 'std::collections::BTreeSet', + 'VecDeque': 'std::collections::VecDeque', + 'BinaryHeap': 'std::collections::BinaryHeap', + 'AtomicBool': 'std::sync::atomic::AtomicBool', + 'AtomicU8': 'std::sync::atomic::AtomicU8', + 'AtomicU16': 'std::sync::atomic::AtomicU16', + 'AtomicU32': 'std::sync::atomic::AtomicU32', + 'AtomicU64': 'std::sync::atomic::AtomicU64', + 'AtomicUsize': 'std::sync::atomic::AtomicUsize', + 'PathBuf': 'std::path::PathBuf', + 'Path': 'std::path::Path', + 'Duration': 'std::time::Duration', + 'Instant': 'std::time::Instant', + 'SystemTime': 'std::time::SystemTime', + 'Mutex': 'std::sync::Mutex', + 'RwLock': 'std::sync::RwLock', + 'RwLockReadGuard': 'std::sync::RwLockReadGuard', + 'RwLockWriteGuard': 'std::sync::RwLockWriteGuard', + 'MutexGuard': 'std::sync::MutexGuard', + 'Arc': 'std::sync::Arc', + 'Cow': 'std::borrow::Cow', +} + +def extract_type_names(t): + names = [] + def split_top(s): + parts, d, cur = [], 0, [] + for ch in s: + if ch == '<': + d += 1 + elif ch == '>': + d -= 1 + if ch == ',' and d == 0: + parts.append(''.join(cur).strip()); cur = [] + else: + cur.append(ch) + if cur: + parts.append(''.join(cur).strip()) + return parts + queue = [t.strip()] + seen = set() + while queue: + expr = queue.pop(0) + if not expr: + continue + expr = re.sub(r'^&(mut )?', '', expr.strip()) + if expr.startswith('fn(') or expr.startswith('impl ') or expr.startswith('dyn '): + continue + m = re.match(r'([A-Za-z_:][A-Za-z0-9_:]*)\s*(<.*>)?', expr, re.S) + if not m: + continue + base, rest = m.group(1), m.group(2) + if base in seen: + pass + seen.add(base) + if not base.startswith(KNOWN_PATH_PREFIX): + names.append(base) + if rest: + for part in split_top(rest[1:-1]): + queue.append(part) + return names + +def normalize_type(t, module): + t = t.strip() + t = re.sub(r"^tauri::State<'_, (Arc<[^>]+>)>$", r"tauri::State<'_, \1>", t) + t = re.sub(r"(?]+>)>$", r"tauri::State<'_, \1>", t) + t = re.sub(r'(? {ret}" if ret else '' + fn_kw = 'async fn' if is_async else 'fn' + await_kw = '.await' if is_async else '' + lines.append(f'{attr}') + lines.append(f'pub {fn_kw} {fnname}(') + lines.extend(sig_params) + lines.append(f'){ret_part} {{') + lines.append(f' {call_target(c)}({", ".join(call_args)}){await_kw}') + lines.append('}') + lines.append('') + while lines and lines[-1] == '': + lines.pop() + out = args.out + with open(out, 'w') as f: + f.write('\n'.join(lines) + '\n') + print(f"wrote {out}: {len(cmds)} adapters, {len(lines)} lines") + +if __name__ == '__main__': + main() diff --git a/scripts/gen_headless.py b/scripts/gen_headless.py new file mode 100644 index 000000000..76b1e1424 --- /dev/null +++ b/scripts/gen_headless.py @@ -0,0 +1,883 @@ +#!/usr/bin/env python3 +"""Generate src/headless.rs invoke dispatch for the headless (no-tauri) runtime. + +[HISTORICAL TOOL] Since the BFF proxy / route-fix commits (the headless.rs +server skeleton) src/headless.rs diverged from this generator and is now +hand-maintained. Its dispatch arms are checked against the committed manifest +by scripts/verify_headless.py in CI. This script is kept for reference only; +do not run it to overwrite src/headless.rs. + +v2: Improved error handling, WebSocket backpressure, auth middleware support. +Generates the complete headless.rs file with: + - Unified HeadlessError type (DesktopOnly / Unavailable / Business) + - WebSocket send queue with backpressure + - Bearer token auth middleware + - Rate limiting support + - Request tracing via eprintln (lightweight, no tracing crate) + +Usage: + python3 scripts/gen_headless.py [--commands /tmp/commands.json] [--types /tmp/type_map.json] +""" +import json, re, sys, argparse +from collections import OrderedDict + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +ap = argparse.ArgumentParser() +ap.add_argument('--commands', default='/tmp/commands.json') +ap.add_argument('--types', default='/tmp/type_map.json') +ap.add_argument('--out', default='crates/agent-gui/src-tauri/src/headless.rs') +args = ap.parse_args() + +cmds = json.load(open(args.commands)) +type_map = json.load(open(args.types)) + +# --------------------------------------------------------------------------- +# Path resolution helpers (shared with gen_adapters.py) +# --------------------------------------------------------------------------- +KNOWN_PATH_PREFIX = ('tauri::', 'crate::', 'super::', 'self::', 'std::') + +FLAT_FILE = { + 'commands/app/app.rs': 'commands::app', + 'commands/app/system.rs': 'commands::system', + 'commands/app/tray.rs': 'commands::tray', + 'commands/app/update.rs': 'commands::update', + 'commands/automation/cron.rs': 'commands::cron', + 'commands/automation/hook.rs': 'commands::hook', + 'commands/history/history_db.rs': 'commands::history_db', + 'commands/history/subagent_store.rs': 'commands::subagent_store', + 'commands/integration/gateway.rs': 'commands::gateway', + 'commands/integration/mcp.rs': 'commands::mcp', + 'commands/integration/memory.rs': 'commands::memory', + 'commands/runtime/process.rs': 'commands::process', + 'commands/runtime/sftp.rs': 'commands::sftp', + 'commands/runtime/shell.rs': 'commands::shell', + 'commands/runtime/terminal.rs': 'commands::terminal', + 'commands/workspace/chat_file_links.rs': 'commands::chat_file_links', + 'commands/workspace/fs.rs': 'commands::fs', + 'commands/workspace/git.rs': 'commands::git', + 'commands/workspace/subagent_worktree.rs': 'commands::subagent_worktree', +} +FLAT_PREFIX = [ + ('commands/config/settings/', 'commands::settings'), + ('commands/history/chat_history/', 'commands::chat_history'), + ('services/memory/', 'services::memory'), + ('runtime/terminal/', 'runtime::terminal'), +] +FLAT_TYPES = { + 'services/gateway/types.rs': 'services::gateway', + 'services/skills/types.rs': 'services::skills', + 'services/automation/types.rs': 'services::automation', + 'runtime/terminal/types.rs': 'runtime::terminal', +} + +def resolve_use_path(rel): + if rel in FLAT_FILE: + return FLAT_FILE[rel] + if rel in FLAT_TYPES: + return FLAT_TYPES[rel] + for prefix, flat in FLAT_PREFIX: + if rel.startswith(prefix): + return flat + if rel.endswith('/mod.rs'): + return rel[:-7].replace('/', '::') + return rel[:-3].replace('/', '::') + +STD_TYPE_IMPORTS = { + 'HashMap': 'std::collections::HashMap', + 'HashSet': 'std::collections::HashSet', + 'BTreeMap': 'std::collections::BTreeMap', + 'BTreeSet': 'std::collections::BTreeSet', + 'VecDeque': 'std::collections::VecDeque', + 'BinaryHeap': 'std::collections::BinaryHeap', + 'AtomicBool': 'std::sync::atomic::AtomicBool', + 'AtomicU8': 'std::sync::atomic::AtomicU8', + 'AtomicU16': 'std::sync::atomic::AtomicU16', + 'AtomicU32': 'std::sync::atomic::AtomicU32', + 'AtomicU64': 'std::sync::atomic::AtomicU64', + 'AtomicUsize': 'std::sync::atomic::AtomicUsize', + 'PathBuf': 'std::path::PathBuf', + 'Path': 'std::path::Path', + 'Duration': 'std::time::Duration', + 'Instant': 'std::time::Instant', + 'SystemTime': 'std::time::SystemTime', + 'Mutex': 'std::sync::Mutex', + 'RwLock': 'std::sync::RwLock', + 'RwLockReadGuard': 'std::sync::RwLockReadGuard', + 'RwLockWriteGuard': 'std::sync::RwLockWriteGuard', + 'MutexGuard': 'std::sync::MutexGuard', + 'Arc': 'std::sync::Arc', + 'Cow': 'std::borrow::Cow', +} + +def extract_type_names(t): + names = [] + def split_top(s): + parts, d, cur = [], 0, [] + for ch in s: + if ch == '<': d += 1 + elif ch == '>': d -= 1 + if ch == ',' and d == 0: + parts.append(''.join(cur).strip()); cur = [] + else: + cur.append(ch) + if cur: parts.append(''.join(cur).strip()) + return parts + queue = [t.strip()]; seen = set() + while queue: + expr = queue.pop(0) + if not expr: continue + expr = re.sub(r'^&(mut )?', '', expr.strip()) + if expr.startswith('fn(') or expr.startswith('impl ') or expr.startswith('dyn '): continue + m = re.match(r'([A-Za-z_:][A-Za-z0-9_:]*)\s*(<.*>)?', expr, re.S) + if not m: continue + base, rest = m.group(1), m.group(2) + seen.add(base) + if not base.startswith(KNOWN_PATH_PREFIX): names.append(base) + if rest: + for part in split_top(rest[1:-1]): queue.append(part) + return names + +# --------------------------------------------------------------------------- +# State mapping +# --------------------------------------------------------------------------- +STATE_MAP = { + 'Arc': '&state.ctx.gateway_controller', + 'Arc': '&state.ctx.memory_store', + 'Arc': '&state.ctx.terminal_registry', + 'Arc': '&state.ctx.sftp_registry', + 'Arc': '&state.ctx.automation_store', + 'Arc': '&state.mcp_runtime', + 'Arc': '&state.ctx.managed_process_registry', + 'Arc': '&state.ctx.git_clone_task_registry', + 'Arc': '&state.hook_scopes', + 'Arc': '&state.shell_runs', + 'Arc': '&state.ctx.power_activity', + 'Arc': '&state.ctx.provider_usage_service', + 'Arc': '&state.ctx.close_window_behavior', + 'Arc': '&state.ctx.allow_exit', + 'Arc': '&state.ctx.automation_scheduler', + 'Arc': '&state.proxy_server', +} +DESKTOP_ONLY_STATES = {'Arc', 'Arc', 'Arc'} +STATE_RE = re.compile(r"^(tauri::)?State<'_, (Arc<[^>]+>)>$") + +# Commands that are truly unavailable in headless (not just desktop-only) +# The original gen_headless.py had system_pick_folder here but the actual +# headless.rs implements a path-based fallback. We only block truly unusable ones. +HEADLESS_UNAVAILABLE = { + 'system_pick_file': 'native file picker', +} + +def is_state(t): return bool(STATE_RE.match(t.strip())) +def state_inner(t): return STATE_RE.match(t.strip()).group(2) +def is_option(t): return bool(re.match(r'^Option<(.+)>$', t.strip(), re.S)) +def option_inner(t): return re.match(r'^Option<(.+)>$', t.strip(), re.S).group(1) + +def call_target(c): + if c['module'] == 'proxy': + return 'crate::services::proxy::proxy_get_server_info' + return f"crate::commands::{c['module']}::{c['name']}" + +def is_desktop_only(c): + for p in c['params']: + t = p['type'].strip() + if re.search(r'AppHandle|tauri::Window', t): return True + if is_state(t) and state_inner(t) in DESKTOP_ONLY_STATES: return True + return False + +def _arm_system_pick_folder(): + """Generate headless fallback for system_pick_folder. + + Accepts a `path` argument from the frontend inline input dialog. + If no path, returns home directory as default. + Validates the path is a directory before returning. + """ + return [ + ' "system_pick_folder" => {', + ' let path_v: Option = take_arg_opt(&mut args, "path")?;', + ' let initial_v: Option = take_arg_opt(&mut args, "initial_workdir")?;', + ' let target = path_v', + ' .or(initial_v)', + ' .unwrap_or_else(|| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/".to_string()));', + ' let p = std::path::Path::new(&target);', + ' if p.is_dir() {', + ' to_value(target)', + ' } else {', + ' Err(HeadlessError::Business(format!("路径不存在或不是目录: {target}")))', + ' }', + ' },', + ] + +def arm_for(c): + name = c['name'] + # Truly headless-unavailable + if name in HEADLESS_UNAVAILABLE: + return [f' "{name}" => Err(HeadlessError::Unavailable("{HEADLESS_UNAVAILABLE[name]}")),'] + # Special case: system_pick_folder has a path-based fallback in headless mode + if name == 'system_pick_folder': + return _arm_system_pick_folder() + # Desktop-only + if is_desktop_only(c): + return [f' "{name}" => Err(HeadlessError::DesktopOnly("{name}")),'] + + prelude, call_args = [], [] + for p in c['params']: + t = p['type'].strip() + if is_state(t): + call_args.append(STATE_MAP[state_inner(t)]) + elif is_option(t): + inner = option_inner(t) + prelude.append(f' let {p["name"]}_v: Option<{inner}> = take_arg_opt(&mut args, "{p["name"]}")?;') + call_args.append(f'{p["name"]}_v') + else: + prelude.append(f' let {p["name"]}_v: {t} = take_arg(&mut args, "{p["name"]}")?;') + call_args.append(f'{p["name"]}_v') + + call = f"{call_target(c)}({', '.join(call_args)})" + ('.await' if c['is_async'] else '') + ret = (c['ret'] or '').strip() + if re.match(r'^Result<', ret): + m = re.match(r'^Result<(.+), (.+)>$', ret, re.S) + err_t = m.group(2).strip() if m else '' + if err_t == 'String': + body = f' match {call} {{\n Ok(v) => to_value(v),\n Err(e) => Err(HeadlessError::Business(e)),\n }}' + else: + body = f' match {call} {{\n Ok(v) => to_value(v),\n Err(e) => Err(HeadlessError::Business(format!("{{e:?}}"))),\n }}' + elif not ret: + body = f' {{ {call}; Ok(Value::Null) }}' + else: + body = f' to_value({call})' + return [f' "{name}" => {{'] + prelude + [body] + [' },'] + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- +L = [] # output lines +def w(s=''): L.append(s) + +# --- Header --- +w('//! Headless runtime (no Tauri): an axum HTTP/WebSocket server that') +w('//! exposes the same business command surface the desktop build exposes') +w('//! via `#[tauri::command]`. Compiled only when the `desktop` feature is') +w('//! off (`--no-default-features`).') +w('//!') +w('//! Routes:') +w('//! GET /health -> { ok, version, mode }') +w('//! GET /api/status -> gateway status snapshot') +w('//! POST /api/invoke -> { cmd, args } -> { ok, value | error }') +w('//! GET /ws -> WebSocket broadcast of frontend events') +w('//! GET /* -> WebUI static assets (SPA fallback)') +w('//!') +w('//! The invoke dispatch below is AUTO-GENERATED by') +w('//! scripts/gen_headless.py — do not edit by hand.') +w('#![cfg(not(feature = "desktop"))]') +w() + +# --- Imports --- +w('use std::collections::HashMap;') +w('use std::path::PathBuf;') +w('use std::sync::Arc;') +w('use std::time::Instant;') +w() +w('use dirs;') +w() +w('use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};') +w('use axum::extract::{Path as AxumPath, State as AxumState};') +w('use axum::http::{header, StatusCode};') +w('use axum::middleware::{self, Next};') +w('use axum::response::{IntoResponse, Response};') +w('use axum::routing::{get, post};') +w('use axum::{Json, Router};') +w('use serde::de::DeserializeOwned;') +w('use serde::Deserialize;') +w('use serde_json::Value;') +w('use tokio::sync::broadcast;') +w('use tower_http::cors::{Any, CorsLayer};') +w() + +# Module-level type imports +needed_uses = {} +std_needed = set() +for c in cmds: + if is_desktop_only(c) or c['name'] in HEADLESS_UNAVAILABLE: + continue + for p in c['params']: + if is_state(p['type'].strip()): continue + for name in extract_type_names(p['type']): + if name in type_map and name != 'Arc': + needed_uses[name] = type_map[name] + elif name in STD_TYPE_IMPORTS and name != 'Arc': + std_needed.add(name) + +module_imports = {} +for name, rel in sorted(needed_uses.items()): + modpath = resolve_use_path(rel) + module_imports.setdefault(modpath, []).append(name) +for modpath in sorted(module_imports): + names = sorted(set(module_imports[modpath])) + w(f'use crate::{modpath}::{{{", ".join(names)}}};') + +w() +w('use crate::app_context::AppContext;') +w('use crate::events::WsEventEmitter;') +w('use crate::runtime::shell_runner::ShellRunRegistry;') +w('use crate::services::proxy::ProxyServerState;') +w() + +# --- HeadlessError --- +w('// ---- Unified error type for headless command dispatch ----') +w() +w('#[derive(Debug)]') +w('pub enum HeadlessError {') +w(' /// Command only available in the desktop build (requires AppHandle / Window).') +w(' DesktopOnly(&\'static str),') +w(' /// Feature unavailable in headless (e.g. native file picker).') +w(' Unavailable(&\'static str),') +w(' /// Business-logic error forwarded from the underlying command.') +w(' Business(String),') +w('}') +w() +w('impl std::fmt::Display for HeadlessError {') +w(' fn fmt(&self, f: &mut std::fmt::Formatter<\'_>) -> std::fmt::Result {') +w(' match self {') +w(' HeadlessError::DesktopOnly(cmd) => write!(f, "command `{cmd}` is only available in desktop mode"),') +w(' HeadlessError::Unavailable(what) => write!(f, "{what} is unavailable in headless mode"),') +w(' HeadlessError::Business(msg) => write!(f, "{msg}"),') +w(' }') +w(' }') +w('}') +w() +w('impl std::error::Error for HeadlessError {}') +w() +w('impl From for HeadlessError {') +w(' fn from(s: String) -> Self { HeadlessError::Business(s) }') +w('}') +w() + +# --- HeadlessState --- +w('// ---- Shared headless state ----') +w() +w('#[derive(Clone)]') +w('pub struct HeadlessState {') +w(' pub ctx: Arc,') +w(' pub emitter: Arc,') +w(' pub mcp_runtime: Arc,') +w(' pub shell_runs: Arc,') +w(' pub hook_scopes: Arc,') +w(' pub proxy_server: Arc,') +w('}') +w() + +# --- Arg helpers --- +w('// ---- Argument helpers ----') +w() +w('fn camelize(name: &str) -> String {') +w(' let mut out = String::with_capacity(name.len());') +w(' let mut upper = false;') +w(' for ch in name.chars() {') +w(' if ch == \'_\' { upper = true; }') +w(' else if upper { out.extend(ch.to_uppercase()); upper = false; }') +w(' else { out.push(ch); }') +w(' }') +w(' out') +w('}') +w() +w('fn remove_arg(obj: &mut serde_json::Map, name: &str) -> Option {') +w(' if let Some(v) = obj.remove(name) { return Some(v); }') +w(' let camel = camelize(name);') +w(' if camel != name { obj.remove(&camel) } else { None }') +w('}') +w() +w('fn take_arg(args: &mut Value, name: &str) -> Result {') +w(' let obj = args.as_object_mut().ok_or_else(|| HeadlessError::Business("args must be a JSON object".into()))?;') +w(' let value = remove_arg(obj, name).ok_or_else(|| HeadlessError::Business(format!("missing argument `{name}`")))?;') +w(' serde_json::from_value(value).map_err(|e| HeadlessError::Business(format!("argument `{name}`: {e}")))') +w('}') +w() +w('fn take_arg_opt(args: &mut Value, name: &str) -> Result, HeadlessError> {') +w(' let obj = args.as_object_mut().ok_or_else(|| HeadlessError::Business("args must be a JSON object".into()))?;') +w(' match remove_arg(obj, name) {') +w(' None | Some(Value::Null) => Ok(None),') +w(' Some(value) => serde_json::from_value(value).map(Some)') +w(' .map_err(|e| HeadlessError::Business(format!("argument `{name}`: {e}"))),') +w(' }') +w('}') +w() +w('fn to_value(v: T) -> Result {') +w(' serde_json::to_value(v).map_err(|e| HeadlessError::Business(format!("serialize result: {e}")))') +w('}') +w() + +# --- Dispatch --- +n_arms = 0 +w('// ---- Command dispatch (AUTO-GENERATED) ----') +w() +w('pub async fn dispatch(state: &HeadlessState, cmd: &str, args: Value) -> Result {') +w(' let mut args = args;') +w(' match cmd {') +by_mod = OrderedDict() +for c in cmds: + by_mod.setdefault(c['module'], []).append(c) +for module, clist in by_mod.items(): + w(f' // ===== {module} =====') + for c in clist: + L.extend(arm_for(c)) + n_arms += 1 +w(' _ => Err(HeadlessError::Business(format!("unknown command: {{cmd}}"))),') +w(' }') +w('}') +w() + +# --- Auth middleware --- +w('// ---- Authentication middleware ----') +w() +w('/// Bearer token configuration loaded from environment.') +w('#[derive(Clone)]') +w('pub struct AuthConfig {') +w(' /// Expected Bearer token; `None` = auth disabled.') +w(' pub api_token: Option,') +w('}') +w() +w('impl AuthConfig {') +w(' pub fn from_env() -> Self {') +w(' let api_token = std::env::var("LIVEAGENT_API_TOKEN")') +w(' .ok().filter(|t| !t.is_empty());') +w(' Self { api_token }') +w(' }') +w('}') +w() +w('async fn auth_middleware(') +w(' State(config): State,') +w(' req: axum::http::Request,') +w(' next: Next,') +w(') -> Result {') +w(' // Health check and WebSocket are always public.') +w(' let path = req.uri().path().to_string();') +w(' if path == "/health" || path == "/api/status" || path == "/ws" || path == "/" {') +w(' return Ok(next.run(req).await);') +w(' }') +w(' match &config.api_token {') +w(' None => Ok(next.run(req).await),') +w(' Some(expected) => {') +w(' let ok = req.headers().get(header::AUTHORIZATION)') +w(' .and_then(|v| v.to_str().ok())') +w(' .and_then(|v| v.strip_prefix("Bearer "))') +w(' .map_or(false, |t| t == expected.as_str());') +w(' if ok { Ok(next.run(req).await) } else { Err(StatusCode::UNAUTHORIZED) }') +w(' }') +w(' }') +w('}') +w() + +# --- Rate limiter (simple per-IP token bucket) --- +w('// ---- Rate limiting ----') +w() +w('use std::sync::Mutex;') +w('use std::collections::hash_map::Entry;') +w() +w('/// Simple in-memory per-IP rate limiter (token bucket).') +w('#[derive(Clone)]') +w('pub struct RateLimiter {') +w(' inner: Arc>>,') +w(' max_tokens: u32,') +w(' refill_interval: std::time::Duration,') +w('}') +w() +w('impl RateLimiter {') +w(' pub fn new(max_tokens: u32, refill_interval: std::time::Duration) -> Self {') +w(' Self { inner: Arc::new(Mutex::new(HashMap::new())), max_tokens, refill_interval }') +w(' }') +w(' /// Returns `true` if the request is allowed.') +w(' pub fn allow(&self, key: &str) -> bool {') +w(' let mut map = self.inner.lock().unwrap();') +w(' let now = Instant::now();') +w(' let entry = map.entry(key.to_string()).or_insert((self.max_tokens, now));') +w(' let elapsed = now.duration_since(entry.1).as_secs_f64();') +w(' let refill = (elapsed / self.refill_interval.as_secs_f64() * self.max_tokens as f64) as u32;') +w(' if refill > 0 {') +w(' entry.0 = (entry.0 + refill).min(self.max_tokens);') +w(' entry.1 = now;') +w(' }') +w(' if entry.0 > 0 { entry.0 -= 1; true } else { false }') +w(' }') +w('}') +w() +w('async fn rate_limit_middleware(') +w(' State(limiter): State,') +w(' req: axum::http::Request,') +w(' next: Next,') +w(') -> Result {') +w(' // Only rate-limit /api/invoke') +w(' if req.uri().path() != "/api/invoke" {') +w(' return Ok(next.run(req).await);') +w(' }') +w(' // Extract IP from X-Forwarded-For or socket addr') +w(' let ip = req.headers().get("x-forwarded-for")') +w(' .and_then(|v| v.to_str().ok())') +w(" .and_then(|v| v.split(',').next())") +w(' .unwrap_or("127.0.0.1")') +w(' .trim().to_string();') +w(' // Loopback (local web UI) is trusted tooling: exempt from rate limiting.') +w(' // Without this, the browser\'s parallel frontend requests quickly exhaust') +w(' // the token bucket and the UI shows HTTP 429 for every invoke.') +w(' let loopback = ip == "127.0.0.1"') +w(' || ip == "::1"') +w(' || ip.starts_with("::1%")') +w(' || ip == "localhost";') +w(' if loopback {') +w(' return Ok(next.run(req).await);') +w(' }') +w(' if limiter.allow(&ip) {') +w(' Ok(next.run(req).await)') +w(' } else {') +w(' eprintln!("[rate-limit] rejected {ip}");') +w(' Err(StatusCode::TOO_MANY_REQUESTS)') +w(' }') +w('}') +w() + +# --- WebSocket with backpressure --- +w('// ---- WebSocket broadcast with backpressure ----') +w() +w('/// Maximum pending messages per WebSocket client before oldest are dropped.') +w('const WS_SEND_QUEUE_LIMIT: usize = 256;') +w('/// Log every N dropped events to avoid log flooding.') +w('const WS_LAGGED_LOG_INTERVAL: u64 = 100;') +w() +w('async fn handle_ws(mut socket: WebSocket, state: HeadlessState) {') +w(' let mut rx = state.emitter.subscribe();') +w(' let mut lagged_total: u64 = 0;') +w(' let mut pending: Vec = Vec::new();') +w() +w(' loop {') +w(' // Phase 1: receive new events and enqueue') +w(' while let Ok(event) = rx.try_recv() {') +w(' match event {') +w(' Ok(ev) => {') +w(' if let Ok(text) = serde_json::to_string(&ev) {') +w(' if pending.len() >= WS_SEND_QUEUE_LIMIT {') +w(' pending.remove(0);') +w(' lagged_total += 1;') +w(' if lagged_total % WS_LAGGED_LOG_INTERVAL == 0 {') +w(' eprintln!("[ws] backpressure: {lagged_total} events dropped");') +w(' }') +w(' }') +w(' pending.push(text);') +w(' }') +w(' }') +w(' Err(broadcast::error::RecvError::Lagged(n)) => {') +w(' lagged_total += n as u64;') +w(' if lagged_total % WS_LAGGED_LOG_INTERVAL == 0 {') +w(' eprintln!("[ws] broadcast lagged: {lagged_total} total");') +w(' }') +w(' }') +w(' Err(broadcast::error::RecvError::Closed) => return,') +w(' }') +w(' }') +w() +w(' // Phase 2: flush pending to socket') +w(' while let Some(text) = pending.first() {') +w(' match tokio::time::timeout(') +w(' std::time::Duration::from_millis(50),') +w(' socket.send(Message::Text(text.as_str().into())),') +w(' ).await {') +w(' Ok(Ok(_)) => { pending.remove(0); }') +w(' _ => {') +w(' // Send failed or timed out — client is slow') +w(' eprintln!("[ws] send timeout/failure, dropping {} pending", pending.len());') +w(' pending.clear();') +w(' if lagged_total > 0 {') +w(' eprintln!("[ws] client disconnected after {lagged_total} total drops");') +w(' }') +w(' return;') +w(' }') +w(' }') +w(' }') +w() +w(' // Phase 3: wait for next event or yield') +w(' match tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()).await {') +w(' Ok(Ok(event)) => {') +w(' match event {') +w(' Ok(ev) => {') +w(' if let Ok(text) = serde_json::to_string(&ev) { pending.push(text); }') +w(' }') +w(' Err(broadcast::error::RecvError::Lagged(n)) => {') +w(' lagged_total += n as u64;') +w(' }') +w(' Err(broadcast::error::RecvError::Closed) => return,') +w(' }') +w(' }') +w(' _ => {} // timeout — loop back to receive more') +w(' }') +w(' }') +w('}') +w() + +# --- Health / status --- +w('// ---- HTTP handlers ----') +w() +w('async fn health(AxumState(_state): AxumState) -> Json {') +w(' Json(serde_json::json!({') +w(' "ok": true,') +w(' "version": crate::app_version(),') +w(' "mode": "headless",') +w(' }))') +w('}') +w() +w('async fn api_status(AxumState(state): AxumState) -> Json {') +w(' match crate::commands::gateway::gateway_status(&state.ctx.gateway_controller) {') +w(' Ok(snapshot) => Json(serde_json::json!({ "ok": true, "gateway": snapshot })),') +w(' Err(error) => Json(serde_json::json!({ "ok": false, "error": error })),') +w(' }') +w('}') +w() + +# --- Invoke handler --- +w('#[derive(Deserialize)]') +w('struct InvokeRequest {') +w(' cmd: String,') +w(' args: Option,') +w('}') +w() +w('async fn invoke_handler(') +w(' AxumState(state): AxumState,') +w(' Json(req): Json,') +w(') -> Json {') +w(' let t0 = Instant::now();') +w(' let args = req.args.unwrap_or(Value::Null);') +w(' let result = dispatch(&state, &req.cmd, args).await;') +w(' let elapsed_ms = t0.elapsed().as_millis();') +w(' match result {') +w(' Ok(value) => {') +w(' if elapsed_ms > 1000 {') +w(' eprintln!("[invoke] {} ok in {elapsed_ms}ms", req.cmd);') +w(' }') +w(' Json(serde_json::json!({ "ok": true, "value": value }))') +w(' }') +w(' Err(error) => {') +w(' let error_code = match &error {') +w(' HeadlessError::DesktopOnly(_) => "DESKTOP_ONLY",') +w(' HeadlessError::Unavailable(_) => "UNAVAILABLE",') +w(' HeadlessError::Business(_) => "BUSINESS_ERROR",') +w(' };') +w(' eprintln!("[invoke] {} err ({error_code}): {error}", req.cmd);') +w(' Json(serde_json::json!({') +w(' "ok": false,') +w(' "error": error.to_string(),') +w(' "code": error_code,') +w(' }))') +w(' }') +w(' }') +w('}') +w() + +# --- WebSocket upgrade --- +w('async fn ws_handler(') +w(' ws: WebSocketUpgrade,') +w(' AxumState(state): AxumState,') +w(') -> impl IntoResponse {') +w(' ws.on_upgrade(move |socket| handle_ws(socket, state))') +w('}') +w() + +# --- Embedded static files --- +w('// ---- Static file serving (compile-time or runtime) ----') +w() +w('#[cfg(not(feature = "runtime-fallback"))]') +w('mod embedded {') +w(' include!(concat!(env!("OUT_DIR"), "/embedded_web.rs"));') +w('}') +w() +w('/// Serve embedded or runtime static files with SPA fallback.') +w('async fn serve_static(') +w(' AxumPath(path): AxumPath,') +w(') -> impl IntoResponse {') +w(' #[cfg(not(feature = "runtime-fallback"))]') +w(' {') +w(' let file_path = if path.is_empty() || path == "/" { "index.html".to_string() }') +w(' else { path.trim_start_matches(\'/\').to_string() };') +w(' match embedded::EMBEDDED_FILES.get(file_path.as_str()) {') +w(' Some(content) => {') +w(' let ct = embedded::mime_for_path(&file_path);') +w(' ([(header::CONTENT_TYPE, ct.to_string())], *content).into_response()') +w(' }') +w(' None => {') +w(' // SPA fallback') +w(' match embedded::EMBEDDED_FILES.get("index.html") {') +w(' Some(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], *html).into_response(),') +w(' None => StatusCode::NOT_FOUND.into_response(),') +w(' }') +w(' }') +w(' }') +w(' }') +w(' #[cfg(feature = "runtime-fallback")]') +w(' {') +w(' use tower_http::services::ServeDir;') +w(' let root = web_root().ok_or(StatusCode::NOT_FOUND)?;') +w(' let file = tokio::fs::read(root.join(&path)).await;') +w(' match file {') +w(' Ok(bytes) => {') +w(' let ct = runtime_mime_for_path(&path);') +w(' ([(header::CONTENT_TYPE, ct.to_string())], bytes).into_response()') +w(' }') +w(' Err(_) => {') +w(' // SPA fallback') +w(' match tokio::fs::read(root.join("index.html")).await {') +w(' Ok(bytes) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], bytes).into_response(),') +w(' Err(_) => StatusCode::NOT_FOUND.into_response(),') +w(' }') +w(' }') +w(' }') +w(' }') +w('}') +w() + +# --- Router --- +w('// ---- Router ----') +w() +w('pub fn build_router(state: HeadlessState) -> Router {') +w(' let cors = CorsLayer::new()') +w(' .allow_origin(Any)') +w(' .allow_methods(Any)') +w(' .allow_headers(Any);') +w() +w(' let auth = AuthConfig::from_env();') +w(' // Default: 60 requests per minute for /api/invoke') +w(' let limiter = RateLimiter::new(60, std::time::Duration::from_secs(60));') +w() +w(' Router::new()') +w(' .route("/health", get(health))') +w(' .route("/api/status", get(api_status))') +w(' .route("/api/invoke", post(invoke_handler))') +w(' .route("/ws", get(ws_handler))') +w(' .fallback(get(serve_static))') +w(' .with_state(state)') +w(' .layer(middleware::from_fn_with_state(auth, auth_middleware))') +w(' .layer(middleware::from_fn_with_state(limiter, rate_limit_middleware))') +w(' .layer(cors)') +w('}') +w() + +# --- Build state --- +w('/// Build the axum state (registries that are not part of AppContext).') +w('pub fn build_state(ctx: Arc, emitter: Arc) -> Result {') +w(' let mcp_runtime = Arc::new(crate::commands::mcp::McpRuntimeManager::default());') +w(' let shell_runs = Arc::new(ShellRunRegistry::default());') +w(' let hook_scopes = Arc::new(crate::commands::hook::HookScopeRegistry::default());') +w(' let proxy_server = crate::services::proxy::start_proxy_server()?;') +w(' Ok(HeadlessState { ctx, emitter, mcp_runtime, shell_runs, hook_scopes, proxy_server })') +w('}') +w() + +# --- Serve entry point --- +w('/// Run the headless server. Config via environment variables:') +w('/// LIVEAGENT_HEADLESS_PORT (default 17890)') +w('/// LIVEAGENT_HEADLESS_HOST (default 127.0.0.1)') +w('/// LIVEAGENT_API_TOKEN (optional; enables Bearer auth)') +w('/// LIVEAGENT_WEB_ROOT (optional; override WebUI dist path)') +w('pub async fn serve() -> Result<(), String> {') +w(' let port = std::env::var("LIVEAGENT_HEADLESS_PORT")') +w(' .ok().and_then(|p| p.parse::().ok()).unwrap_or(17890);') +w(' let host = std::env::var("LIVEAGENT_HEADLESS_HOST")') +w(' .unwrap_or_else(|_| "127.0.0.1".to_string());') +w() +w(' let (tx, _) = broadcast::channel(1024);') +w(' let emitter = Arc::new(WsEventEmitter::new(tx));') +w(' let ws_emitter = Arc::clone(&emitter);') +w(' let emitter_dyn: Arc = ws_emitter;') +w() +w(' // Initialize (aligned with desktop setup): history DB migration, staging GC, builtin skills.') +w(' crate::commands::history_db::initialize_history_db()') +w(' .map_err(|e| format!("history db init: {e}"))?;') +w(' if let Err(error) = crate::commands::settings::initialize_system_proxy_from_db() {') +w(' eprintln!("failed to initialize system proxy state: {error}");') +w(' }') +w(' crate::commands::system::gc_upload_staging_on_startup();') +w(' if let Err(error) = crate::services::skills::ensure_builtin_agent_skills_sync() {') +w(' eprintln!("failed to seed builtin skills: {error}");') +w(' }') +w() +w(' let ctx = AppContext::new(emitter_dyn);') +w(' let state = build_state(ctx, emitter).map_err(|e| format!("headless state: {e}"))?;') +w(' let app = build_router(state);') +w(' let listener = tokio::net::TcpListener::bind((host.as_str(), port))') +w(' .await.map_err(|e| format!("bind {host}:{port}: {e}"))?;') +w() +w(' let has_auth = AuthConfig::from_env().api_token.is_some();') +w(' eprintln!("LiveAgent headless listening on http://{host}:{port} (auth={has_auth})");') +w(' axum::serve(listener, app).await.map_err(|e| e.to_string())') +w('}') +w() + +# --- Runtime fallback helpers (only compiled with runtime-fallback feature) --- +w('#[cfg(feature = "runtime-fallback")]') +w('fn web_root() -> Option {') +w(' if let Ok(root) = std::env::var("LIVEAGENT_WEB_ROOT") {') +w(' let root = PathBuf::from(root);') +w(' if root.is_dir() { return Some(root); }') +w(' eprintln!("LiveAgent headless: LIVEAGENT_WEB_ROOT={} not found, falling back", root.display());') +w(' }') +w(' [PathBuf::from("../dist"), PathBuf::from("dist")].into_iter().find(|c| c.is_dir())') +w('}') +w() +w('#[cfg(feature = "runtime-fallback")]') +w('fn runtime_mime_for_path(path: &str) -> \x26\'static str {') +w(' match path.rsplit(\'.\').next() {') +w(' Some("html") => "text/html; charset=utf-8",') +w(' Some("css") => "text/css; charset=utf-8",') +w(' Some("js") | Some("mjs") => "application/javascript; charset=utf-8",') +w(' Some("json") => "application/json",') +w(' Some("svg") => "image/svg+xml",') +w(' Some("png") => "image/png",') +w(' Some("jpg") | Some("jpeg") => "image/jpeg",') +w(' _ => "application/octet-stream",') +w(' }') +w('}') +w() + +# --- Tests --- +w('#[cfg(test)]') +w('mod tests {') +w(' use super::*;') +w(' use serde_json::{json, Map};') +w() +w(' #[test]') +w(' fn camelize_snake_to_camel() {') +w(' assert_eq!(camelize("page_size"), "pageSize");') +w(' assert_eq!(camelize("single"), "single");') +w(' assert_eq!(camelize(""), "");') +w(' }') +w() +w(' #[test]') +w(' fn remove_arg_snake_and_camel() {') +w(' let mut o = Map::new(); o.insert("page_size".into(), json!(10));') +w(' assert_eq!(remove_arg(&mut o, "page_size"), Some(json!(10)));') +w(' let mut o = Map::new(); o.insert("pageSize".into(), json!(20));') +w(' assert_eq!(remove_arg(&mut o, "page_size"), Some(json!(20)));') +w(' }') +w() +w(' #[test]') +w(' fn take_arg_missing_returns_business_error() {') +w(' let mut a = json!({});') +w(' let err = take_arg::(&mut a, "x").unwrap_err();') +w(' assert!(matches!(err, HeadlessError::Business(_)));') +w(' }') +w() +w(' #[test]') +w(' fn rate_limiter_basic() {') +w(' let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60));') +w(' assert!(limiter.allow("ip1"));') +w(' assert!(limiter.allow("ip1"));') +w(' assert!(limiter.allow("ip1"));') +w(' assert!(!limiter.allow("ip1")); // exhausted') +w(' assert!(limiter.allow("ip2")); // different key') +w(' }') +w('}') +w() + +# --- Write output --- +out_path = args.out +with open(out_path, 'w') as f: + f.write('\n'.join(L) + '\n') +print(f"[gen_headless] wrote {out_path}: {n_arms} dispatch arms, {len(L)} lines") diff --git a/scripts/gen_headless.sh b/scripts/gen_headless.sh new file mode 100644 index 000000000..0c312fd80 --- /dev/null +++ b/scripts/gen_headless.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Regenerate the generated adapter layer for the headless/desktop dual build. +# +# Usage: +# scripts/gen_headless.sh +# +# Pipeline: +# 1. scripts/build_type_map.py -> type-name mapping from src/*.rs (target/gen-meta) +# 2. scripts/gen_adapters.py -> src/commands/adapters.rs (from scripts/manifest/commands.json) +# +# The command manifest (scripts/manifest/commands.json) is the committed source +# of truth for the 234 Tauri commands. When you add/remove/rename a command: +# - update scripts/manifest/commands.json +# - add the business fn in src/commands/* +# - run this script +# - add/keep the matching dispatch arm in src/headless.rs (verified by +# scripts/verify_headless.py in CI) +# +# This script does NOT touch src/headless.rs: it is the hand-maintained server +# skeleton and is only checked (not regenerated) for dispatch coverage. +set -euo pipefail +REPO="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO" + +META="crates/agent-gui/src-tauri/target/gen-meta" +mkdir -p "$META" + +python3 scripts/build_type_map.py --out "$META/type_map.json" +python3 scripts/gen_adapters.py \ + --commands "scripts/manifest/commands.json" \ + --types "$META/type_map.json" \ + --out "crates/agent-gui/src-tauri/src/commands/adapters.rs" + +echo +echo "done. regenerated metadata + adapters.rs" +echo "no-drift check: git diff --exit-code crates/agent-gui/src-tauri/src/commands/adapters.rs" +echo "dispatch check: python3 scripts/verify_headless.py" \ No newline at end of file diff --git a/scripts/manifest/commands.json b/scripts/manifest/commands.json new file mode 100644 index 000000000..135e1d67a --- /dev/null +++ b/scripts/manifest/commands.json @@ -0,0 +1,4983 @@ +[ + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_window_pinned", + "is_async": false, + "params": [ + { + "name": "pin_state", + "type": "State<'_, Arc>" + } + ], + "ret": "bool", + "line": 26 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_toggle_window_pin", + "is_async": false, + "params": [ + { + "name": "app", + "type": "AppHandle" + } + ], + "ret": null, + "line": 33 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_set_global_shortcuts", + "is_async": false, + "params": [ + { + "name": "app", + "type": "AppHandle" + }, + { + "name": "bindings", + "type": "Vec" + }, + { + "name": "registry", + "type": "State<'_, Arc>" + } + ], + "ret": "Result, String>", + "line": 72 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_runtime_platform", + "is_async": false, + "params": [], + "ret": "RuntimePlatformResponse", + "line": 139 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_set_close_window_behavior", + "is_async": false, + "params": [ + { + "name": "behavior", + "type": "String" + }, + { + "name": "close_window_behavior", + "type": "State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 151 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_confirmed_exit", + "is_async": false, + "params": [ + { + "name": "app", + "type": "AppHandle" + }, + { + "name": "allow_exit", + "type": "State<'_, Arc>" + }, + { + "name": "terminal_registry", + "type": "State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 160 + }, + { + "attr": "#[tauri::command]", + "file": "app/app.rs", + "module": "app", + "name": "app_macos_traffic_light_metrics", + "is_async": true, + "params": [ + { + "name": "window", + "type": "tauri::Window" + } + ], + "ret": "Result, String>", + "line": 173 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/tray.rs", + "module": "tray", + "name": "app_tray_menu_sync", + "is_async": true, + "params": [ + { + "name": "app", + "type": "tauri::AppHandle" + }, + { + "name": "model", + "type": "TrayMenuModel" + }, + { + "name": "handles", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 8 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/update.rs", + "module": "update", + "name": "app_update_check", + "is_async": true, + "params": [ + { + "name": "app", + "type": "AppHandle" + }, + { + "name": "include_prerelease", + "type": "bool" + } + ], + "ret": "Result", + "line": 472 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/update.rs", + "module": "update", + "name": "app_update_install", + "is_async": true, + "params": [ + { + "name": "app", + "type": "AppHandle" + }, + { + "name": "include_prerelease", + "type": "bool" + } + ], + "ret": "Result", + "line": 516 + }, + { + "attr": "#[tauri::command]", + "file": "app/update.rs", + "module": "update", + "name": "app_restart", + "is_async": false, + "params": [ + { + "name": "app", + "type": "AppHandle" + } + ], + "ret": "Result<(), String>", + "line": 571 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_pick_folder", + "is_async": true, + "params": [ + { + "name": "initial_workdir", + "type": "Option" + } + ], + "ret": "Result, String>", + "line": 1336 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_pick_file", + "is_async": true, + "params": [ + { + "name": "initial_workdir", + "type": "Option" + }, + { + "name": "filter_name", + "type": "Option" + }, + { + "name": "extensions", + "type": "Option>" + } + ], + "ret": "Result, String>", + "line": 1352 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_create_project_folder", + "is_async": true, + "params": [ + { + "name": "parent", + "type": "String" + }, + { + "name": "name", + "type": "String" + } + ], + "ret": "Result", + "line": 1376 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_pick_readable_files", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "max_files", + "type": "Option" + } + ], + "ret": "Result", + "line": 1386 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_import_readable_file_paths", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "paths", + "type": "Vec" + }, + { + "name": "max_files", + "type": "Option" + } + ], + "ret": "Result", + "line": 1398 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_import_uploaded_readable_files", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "files", + "type": "Vec" + }, + { + "name": "max_files", + "type": "Option" + } + ], + "ret": "Result", + "line": 1411 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_import_pasted_texts", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "texts", + "type": "Vec" + } + ], + "ret": "Result", + "line": 1424 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_read_uploaded_image_preview", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "absolute_path", + "type": "String" + } + ], + "ret": "Result", + "line": 1444 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_read_uploaded_native_attachment", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "absolute_path", + "type": "Option" + }, + { + "name": "kind", + "type": "Option" + } + ], + "ret": "Result", + "line": 1456 + }, + { + "attr": "#[tauri::command]", + "file": "app/system.rs", + "module": "system", + "name": "system_list_skill_files", + "is_async": true, + "params": [], + "ret": "Result", + "line": 1469 + }, + { + "attr": "#[tauri::command]", + "file": "app/system.rs", + "module": "system", + "name": "system_ensure_builtin_skills", + "is_async": true, + "params": [], + "ret": "Result, String>", + "line": 1476 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_manage_skill", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result", + "line": 1484 + }, + { + "attr": "#[tauri::command]", + "file": "app/system.rs", + "module": "system", + "name": "system_read_skill_text", + "is_async": true, + "params": [ + { + "name": "path", + "type": "String" + }, + { + "name": "offset", + "type": "Option" + }, + { + "name": "length", + "type": "Option" + } + ], + "ret": "Result", + "line": 1493 + }, + { + "attr": "#[tauri::command]", + "file": "app/system.rs", + "module": "system", + "name": "system_read_skill_metadata", + "is_async": true, + "params": [ + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 1504 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_append_debug_jsonl", + "is_async": true, + "params": [ + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "entry", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 1513 + }, + { + "attr": "#[tauri::command]", + "file": "app/system.rs", + "module": "system", + "name": "system_clipboard_read_text", + "is_async": true, + "params": [], + "ret": "Result", + "line": 1539 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_begin_power_activity", + "is_async": false, + "params": [ + { + "name": "activity_id", + "type": "String" + }, + { + "name": "reason", + "type": "String" + }, + { + "name": "ttl_ms", + "type": "Option" + }, + { + "name": "power_activity", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 1546 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "app/system.rs", + "module": "system", + "name": "system_end_power_activity", + "is_async": false, + "params": [ + { + "name": "activity_id", + "type": "String" + }, + { + "name": "power_activity", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 1557 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "cron_validate_expression", + "is_async": true, + "params": [ + { + "name": "expression", + "type": "String" + } + ], + "ret": "Result<(), String>", + "line": 10 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_snapshot", + "is_async": true, + "params": [ + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 17 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_cron_apply", + "is_async": true, + "params": [ + { + "name": "input", + "type": "AutomationApplyInput" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 27 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_hooks_apply", + "is_async": true, + "params": [ + { + "name": "input", + "type": "AutomationApplyInput" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 38 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_list_runs", + "is_async": true, + "params": [ + { + "name": "task_id", + "type": "String" + }, + { + "name": "limit", + "type": "Option" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result, String>", + "line": 49 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_clear_runs", + "is_async": true, + "params": [ + { + "name": "task_id", + "type": "String" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 61 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_run_cron_now", + "is_async": true, + "params": [ + { + "name": "task_id", + "type": "String" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 72 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_claim_prompt_runs", + "is_async": true, + "params": [ + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result, String>", + "line": 83 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_release_prompt_run", + "is_async": true, + "params": [ + { + "name": "execution_id", + "type": "String" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 93 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/cron.rs", + "module": "cron", + "name": "automation_complete_prompt_run", + "is_async": true, + "params": [ + { + "name": "input", + "type": "CompletePromptRunInput" + }, + { + "name": "store", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 104 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/hook.rs", + "module": "hook", + "name": "hook_run_script", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "Option" + }, + { + "name": "script", + "type": "String" + }, + { + "name": "timeout_ms", + "type": "Option" + }, + { + "name": "scope_id", + "type": "Option" + }, + { + "name": "context", + "type": "Option>" + }, + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 270 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/hook.rs", + "module": "hook", + "name": "hook_run_http_requests", + "is_async": true, + "params": [ + { + "name": "requests", + "type": "Vec" + }, + { + "name": "scope_id", + "type": "Option" + }, + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 288 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "automation/hook.rs", + "module": "hook", + "name": "hook_cancel_scope", + "is_async": true, + "params": [ + { + "name": "scope_id", + "type": "String" + }, + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 302 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/ccs_import.rs", + "module": "settings", + "name": "settings_list_ccswitch_providers", + "is_async": true, + "params": [], + "ret": "Result", + "line": 23 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/cherry_import.rs", + "module": "settings", + "name": "settings_list_cherry_studio_providers", + "is_async": true, + "params": [], + "ret": "Result", + "line": 83 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/cherry_import.rs", + "module": "settings", + "name": "settings_list_cherry_studio_providers_from_path", + "is_async": true, + "params": [ + { + "name": "data_path", + "type": "String" + } + ], + "ret": "Result", + "line": 92 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_load_all", + "is_async": true, + "params": [], + "ret": "Result", + "line": 2 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_providers", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 22 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_system", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + }, + { + "name": "automation_scheduler", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 32 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_mcp", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 51 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_remote", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 61 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_memory", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 75 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_agents", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 85 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_save_ssh", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result<(), String>", + "line": 95 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_apply_ssh_patch", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + } + ], + "ret": "Result", + "line": 105 + }, + { + "attr": "#[tauri::command]", + "file": "config/settings/commands.rs", + "module": "settings", + "name": "settings_reset_ssh_known_host", + "is_async": true, + "params": [ + { + "name": "host", + "type": "String" + }, + { + "name": "port", + "type": "u16" + } + ], + "ret": "Result", + "line": 115 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_identity_upsert", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentIdentityUpsertInput" + } + ], + "ret": "Result", + "line": 1255 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_identity_list", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentIdentityListInput" + } + ], + "ret": "Result, String>", + "line": 1267 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_run_save", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentRunSaveInput" + } + ], + "ret": "Result<(), String>", + "line": 1279 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_run_list", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentRunListInput" + } + ], + "ret": "Result, String>", + "line": 1289 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_run_load", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentRunLoadInput" + } + ], + "ret": "Result, String>", + "line": 1301 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_run_prune", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentRunPruneInput" + } + ], + "ret": "Result", + "line": 1313 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_message_append", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentMessageAppendInput" + } + ], + "ret": "Result", + "line": 1322 + }, + { + "attr": "#[tauri::command]", + "file": "history/subagent_store.rs", + "module": "subagent_store", + "name": "subagent_message_list", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentMessageListInput" + } + ], + "ret": "Result, String>", + "line": 1334 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/branch.rs", + "module": "chat_history", + "name": "chat_history_branch", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "base_message_ref", + "type": "ChatHistoryMessageRef" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 226 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_list", + "is_async": true, + "params": [ + { + "name": "page", + "type": "i64" + }, + { + "name": "page_size", + "type": "i64" + }, + { + "name": "cwd", + "type": "Option" + }, + { + "name": "cwd_empty", + "type": "Option" + } + ], + "ret": "Result", + "line": 2 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_workdirs", + "is_async": true, + "params": [], + "ret": "Result", + "line": 25 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_shared_list", + "is_async": true, + "params": [ + { + "name": "page", + "type": "i64" + }, + { + "name": "page_size", + "type": "i64" + } + ], + "ret": "Result", + "line": 35 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_search", + "is_async": true, + "params": [ + { + "name": "args", + "type": "ChatHistorySearchArgs" + } + ], + "ret": "Result", + "line": 47 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_get_window", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "max_messages", + "type": "i64" + }, + { + "name": "before_offset", + "type": "Option" + }, + { + "name": "expected_revision", + "type": "Option" + }, + { + "name": "include_active_segment", + "type": "bool" + } + ], + "ret": "Result", + "line": 214 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_upsert", + "is_async": true, + "params": [ + { + "name": "input", + "type": "ChatHistoryUpsertInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 281 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_upsert_active_segment", + "is_async": true, + "params": [ + { + "name": "input", + "type": "ChatHistorySegmentMutationInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 316 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_append_segment", + "is_async": true, + "params": [ + { + "name": "input", + "type": "ChatHistorySegmentMutationInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 352 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_rename", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "title", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 376 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_set_pinned", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "is_pinned", + "type": "bool" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 401 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_set_model", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "selected_model_json", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 426 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_share_get", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + } + ], + "ret": "Result", + "line": 450 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/commands.rs", + "module": "chat_history", + "name": "chat_history_share_set", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "enabled", + "type": "bool" + }, + { + "name": "redact_tool_content", + "type": "Option" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 468 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/delete.rs", + "module": "chat_history", + "name": "chat_history_delete", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 63 + }, + { + "attr": "#[tauri::command]", + "file": "history/chat_history/replace.rs", + "module": "chat_history", + "name": "chat_history_replace_from_message", + "is_async": true, + "params": [ + { + "name": "id", + "type": "String" + }, + { + "name": "base_message_ref", + "type": "ChatHistoryMessageRef" + }, + { + "name": "replacement_message", + "type": "Value" + }, + { + "name": "max_messages", + "type": "i64" + }, + { + "name": "expected_revision", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 157 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "provider_usage_query", + "is_async": true, + "params": [ + { + "name": "provider_id", + "type": "String" + }, + { + "name": "refresh", + "type": "bool" + }, + { + "name": "provider_usage_service", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 18 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "provider_usage_test", + "is_async": true, + "params": [ + { + "name": "provider_id", + "type": "String" + }, + { + "name": "config_json", + "type": "String" + }, + { + "name": "provider_usage_service", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 27 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_connect", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Option" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 38 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_disconnect", + "is_async": false, + "params": [ + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 60 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_status", + "is_async": false, + "params": [ + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 67 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_nudge_connection", + "is_async": false, + "params": [ + { + "name": "reason", + "type": "Option" + }, + { + "name": "force_reconnect", + "type": "Option" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 74 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_send_chat_ingress_batch", + "is_async": true, + "params": [ + { + "name": "input", + "type": "GatewayChatIngressBatchInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 86 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_commit_chat_checkpoint", + "is_async": true, + "params": [ + { + "name": "input", + "type": "GatewayChatCheckpointInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 94 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_claim_next", + "is_async": true, + "params": [ + { + "name": "worker_id", + "type": "String" + }, + { + "name": "lease_ms", + "type": "Option" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result, String>", + "line": 102 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_mark_started", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 113 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_mark_local_started", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 125 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_mark_local_cancelled", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 136 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_mark_queued_in_gui", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 147 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_complete", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 159 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_fail", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "Option" + }, + { + "name": "error_code", + "type": "String" + }, + { + "name": "message", + "type": "String" + }, + { + "name": "terminal", + "type": "bool" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 171 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_cancel_request", + "is_async": true, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 193 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_heartbeat", + "is_async": false, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 205 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_runtime_heartbeat", + "is_async": true, + "params": [ + { + "name": "worker_id", + "type": "String" + }, + { + "name": "state", + "type": "String" + }, + { + "name": "visible", + "type": "bool" + }, + { + "name": "active_run_count", + "type": "u32" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 214 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_release_lease", + "is_async": false, + "params": [ + { + "name": "request_id", + "type": "String" + }, + { + "name": "worker_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 227 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_chat_queue_respond", + "is_async": false, + "params": [ + { + "name": "input", + "type": "GatewayChatQueueResponseInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 236 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_publish_chat_queue_event", + "is_async": true, + "params": [ + { + "name": "input", + "type": "GatewayChatQueueEventInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 244 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_publish_settings_sync", + "is_async": true, + "params": [ + { + "name": "payload", + "type": "Value" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 252 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_tunnel_state", + "is_async": false, + "params": [ + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result", + "line": 260 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_tunnel_create", + "is_async": true, + "params": [ + { + "name": "input", + "type": "GatewayTunnelCreateInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 267 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_tunnel_update", + "is_async": true, + "params": [ + { + "name": "input", + "type": "GatewayTunnelUpdateInput" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 275 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_tunnel_close", + "is_async": true, + "params": [ + { + "name": "tunnel_id", + "type": "String" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 283 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "gateway_tunnel_check", + "is_async": true, + "params": [ + { + "name": "tunnel_id", + "type": "Option" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 291 + }, + { + "attr": "#[tauri::command]", + "file": "integration/gateway.rs", + "module": "gateway", + "name": "workspace_watch_set", + "is_async": false, + "params": [ + { + "name": "workdirs", + "type": "Vec" + }, + { + "name": "gateway_controller", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result<(), String>", + "line": 299 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_list_tools", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "servers", + "type": "Vec" + } + ], + "ret": "Result, String>", + "line": 1645 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_call_tool", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "run_registry", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "server_id", + "type": "String" + }, + { + "name": "tool_name", + "type": "String" + }, + { + "name": "arguments", + "type": "Value" + }, + { + "name": "run_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 1696 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_runtime_status", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "server_id", + "type": "String" + } + ], + "ret": "Result", + "line": 1754 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_stop_server", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "server_id", + "type": "String" + } + ], + "ret": "Result", + "line": 1766 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_test_server", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "server", + "type": "McpServerConfig" + }, + { + "name": "include_schema", + "type": "Option" + }, + { + "name": "persist", + "type": "Option" + } + ], + "ret": "Result", + "line": 1783 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "integration/mcp.rs", + "module": "mcp", + "name": "mcp_restart_server", + "is_async": true, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "server", + "type": "McpServerConfig" + }, + { + "name": "include_schema", + "type": "Option" + }, + { + "name": "persist", + "type": "Option" + } + ], + "ret": "Result", + "line": 1802 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_list", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryListArgs" + } + ], + "ret": "Result", + "line": 21 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_read", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryReadArgs" + } + ], + "ret": "Result", + "line": 32 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_search", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemorySearchArgs" + } + ], + "ret": "Result", + "line": 43 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_write", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryWriteArgs" + } + ], + "ret": "Result", + "line": 60 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_update", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryUpdateArgs" + } + ], + "ret": "Result", + "line": 71 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_delete", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryDeleteArgs" + } + ], + "ret": "Result", + "line": 82 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_delete_project", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryDeleteProjectArgs" + } + ], + "ret": "Result", + "line": 93 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_accept", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryAcceptArgs" + } + ], + "ret": "Result", + "line": 104 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_apply_batch", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryBatchArgs" + } + ], + "ret": "Result", + "line": 115 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_run_create", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryOrganizeRunCreateArgs" + } + ], + "ret": "Result", + "line": 126 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_run_update", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryOrganizeRunUpdateArgs" + } + ], + "ret": "Result, String>", + "line": 137 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_run_list", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "Option" + } + ], + "ret": "Result", + "line": 148 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_run_read", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryOrganizeRunReadArgs" + } + ], + "ret": "Result, String>", + "line": 160 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_run_clear_history", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + } + ], + "ret": "Result", + "line": 171 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_due_claim", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryOrganizeDueClaimArgs" + } + ], + "ret": "Result", + "line": 181 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_organize_due_complete", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "MemoryOrganizeRunUpdateArgs" + } + ], + "ret": "Result, String>", + "line": 192 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_index_overview", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "workdir", + "type": "Option" + } + ], + "ret": "Result", + "line": 203 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_paths_info", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + } + ], + "ret": "Result", + "line": 214 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_recent_rejections", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "Option" + } + ], + "ret": "Result", + "line": 224 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_today_local_date", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "rollover_hour", + "type": "Option" + } + ], + "ret": "Result", + "line": 236 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_today_daily", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "rollover_hour", + "type": "Option" + } + ], + "ret": "Result, String>", + "line": 244 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_quota_summary", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + }, + { + "name": "args", + "type": "Option" + } + ], + "ret": "Result", + "line": 255 + }, + { + "attr": "#[tauri::command]", + "file": "integration/memory.rs", + "module": "memory", + "name": "memory_wipe_all", + "is_async": true, + "params": [ + { + "name": "state", + "type": "State<'_, Arc>" + } + ], + "ret": "Result", + "line": 267 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_start", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "command", + "type": "String" + }, + { + "name": "cwd", + "type": "Option" + }, + { + "name": "label", + "type": "Option" + }, + { + "name": "isolated", + "type": "Option" + } + ], + "ret": "Result", + "line": 11 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_status", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "process_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 23 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_stop", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "process_id", + "type": "String" + } + ], + "ret": "Result", + "line": 31 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_read_log", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "process_id", + "type": "String" + }, + { + "name": "max_bytes", + "type": "Option" + } + ], + "ret": "Result", + "line": 39 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_snapshot", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + } + ], + "ret": "Result", + "line": 48 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/process.rs", + "module": "process", + "name": "managed_process_clear", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "process_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 55 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_list", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "side", + "type": "String" + }, + { + "name": "path", + "type": "Option" + } + ], + "ret": "Result", + "line": 11 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_stat", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "side", + "type": "String" + }, + { + "name": "path", + "type": "Option" + } + ], + "ret": "Result", + "line": 25 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_read_text", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "offset", + "type": "Option" + }, + { + "name": "max_bytes", + "type": "Option" + } + ], + "ret": "Result", + "line": 39 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_write_text", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "content", + "type": "String" + }, + { + "name": "overwrite", + "type": "Option" + }, + { + "name": "create_parent_dirs", + "type": "Option" + } + ], + "ret": "Result", + "line": 53 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_mkdir", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "side", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 75 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_rename", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "side", + "type": "String" + }, + { + "name": "from_path", + "type": "String" + }, + { + "name": "to_path", + "type": "String" + } + ], + "ret": "Result", + "line": 89 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_delete", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "side", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "recursive", + "type": "Option" + } + ], + "ret": "Result", + "line": 111 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_transfer", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "direction", + "type": "String" + }, + { + "name": "source_path", + "type": "String" + }, + { + "name": "target_path", + "type": "String" + }, + { + "name": "recursive", + "type": "Option" + }, + { + "name": "overwrite", + "type": "Option" + } + ], + "ret": "Result", + "line": 133 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_cancel_transfer", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "transfer_id", + "type": "String" + } + ], + "ret": "Result<(), String>", + "line": 161 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/sftp.rs", + "module": "sftp", + "name": "sftp_transfer_status", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "transfer_id", + "type": "String" + } + ], + "ret": "Result", + "line": 170 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_shell_options", + "is_async": false, + "params": [], + "ret": "TerminalShellOptionsResponse", + "line": 17 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_list", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "project_path_key", + "type": "Option" + } + ], + "ret": "TerminalListResponse", + "line": 22 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_create", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "cwd", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "shell", + "type": "Option" + }, + { + "name": "title", + "type": "Option" + }, + { + "name": "cols", + "type": "Option" + }, + { + "name": "rows", + "type": "Option" + } + ], + "ret": "Result", + "line": 30 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_create_ssh", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "cwd", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "ssh_host_id", + "type": "String" + }, + { + "name": "title", + "type": "Option" + }, + { + "name": "cols", + "type": "Option" + }, + { + "name": "rows", + "type": "Option" + }, + { + "name": "sftp_enabled", + "type": "Option" + } + ], + "ret": "Result", + "line": 43 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_answer_ssh_prompt", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "prompt_id", + "type": "String" + }, + { + "name": "prompt_answer", + "type": "Option" + }, + { + "name": "trust_host_key", + "type": "Option" + } + ], + "ret": "Result", + "line": 69 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_cancel_ssh_prompt", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "prompt_id", + "type": "String" + } + ], + "ret": "Result<(), String>", + "line": 83 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_reconnect", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + } + ], + "ret": "Result", + "line": 91 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_latency", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + } + ], + "ret": "Result", + "line": 99 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_exec", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "run_registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "command", + "type": "String" + }, + { + "name": "cwd", + "type": "Option" + }, + { + "name": "timeout_ms", + "type": "Option" + }, + { + "name": "max_bytes", + "type": "Option" + }, + { + "name": "run_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 107 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_local_forward_start", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "project_path_key", + "type": "Option" + }, + { + "name": "remote_host", + "type": "String" + }, + { + "name": "remote_port", + "type": "u32" + }, + { + "name": "local_port", + "type": "Option" + } + ], + "ret": "Result", + "line": 143 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_local_forward_list", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "Option" + }, + { + "name": "project_path_key", + "type": "Option" + } + ], + "ret": "Result", + "line": 165 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_local_forward_stop", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "forward_id", + "type": "String" + }, + { + "name": "session_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 174 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_ssh_local_forward_check_port", + "is_async": true, + "params": [ + { + "name": "local_port", + "type": "u32" + } + ], + "ret": "Result", + "line": 185 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "ssh_terminal_tabs_list", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "project_path_key", + "type": "String" + } + ], + "ret": "Result", + "line": 195 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "ssh_terminal_tab_open", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "kind", + "type": "String" + } + ], + "ret": "Result", + "line": 203 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "ssh_terminal_tab_close", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "tab_id", + "type": "String" + } + ], + "ret": "Result", + "line": 212 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_stream_attach", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "max_bytes", + "type": "Option" + } + ], + "ret": "Result", + "line": 220 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_stream_input", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "bytes", + "type": "Vec" + } + ], + "ret": "Result<(), String>", + "line": 229 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_stream_resize", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "cols", + "type": "u16" + }, + { + "name": "rows", + "type": "u16" + } + ], + "ret": "Result<(), String>", + "line": 238 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_rename", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + }, + { + "name": "title", + "type": "String" + } + ], + "ret": "Result", + "line": 248 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_close", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "sftp_registry", + "type": "State<'_, Arc>" + }, + { + "name": "session_id", + "type": "String" + } + ], + "ret": "Result", + "line": 257 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_close_project", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "sftp_registry", + "type": "State<'_, Arc>" + }, + { + "name": "project_path_key", + "type": "String" + } + ], + "ret": "Result", + "line": 268 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/terminal.rs", + "module": "terminal", + "name": "terminal_read_tail", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "project_path_key", + "type": "String" + }, + { + "name": "session_id", + "type": "Option" + }, + { + "name": "max_bytes", + "type": "Option" + } + ], + "ret": "Result", + "line": 281 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/shell.rs", + "module": "shell", + "name": "shell_run", + "is_async": true, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "command", + "type": "String" + }, + { + "name": "cwd", + "type": "Option" + }, + { + "name": "timeout_ms", + "type": "Option" + }, + { + "name": "max_timeout_ms", + "type": "Option" + }, + { + "name": "provider_id", + "type": "Option" + }, + { + "name": "run_id", + "type": "Option" + } + ], + "ret": "Result", + "line": 14 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "runtime/shell.rs", + "module": "shell", + "name": "runtime_cancel", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "State<'_, Arc>" + }, + { + "name": "run_id", + "type": "String" + } + ], + "ret": "ShellCancelResponse", + "line": 53 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/chat_file_links.rs", + "module": "chat_file_links", + "name": "open_chat_file_link", + "is_async": true, + "params": [ + { + "name": "conversation_id", + "type": "String" + }, + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "source", + "type": "String" + }, + { + "name": "line", + "type": "Option" + }, + { + "name": "end_line", + "type": "Option" + }, + { + "name": "column", + "type": "Option" + }, + { + "name": "open_in_file_manager", + "type": "Option" + } + ], + "ret": "Result", + "line": 628 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_read_image_source", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "source", + "type": "String" + }, + { + "name": "source_type", + "type": "Option" + }, + { + "name": "mime_type", + "type": "Option" + } + ], + "ret": "Result", + "line": 2491 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_read_workspace_image", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 2519 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_read_text", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "start_line", + "type": "Option" + }, + { + "name": "limit", + "type": "Option" + }, + { + "name": "page_start", + "type": "Option" + }, + { + "name": "page_limit", + "type": "Option" + }, + { + "name": "cell_start", + "type": "Option" + }, + { + "name": "cell_limit", + "type": "Option" + } + ], + "ret": "Result", + "line": 2752 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_read_editable_text", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 2843 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_path_status", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 2914 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_write_text", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "content", + "type": "String" + }, + { + "name": "mode", + "type": "String" + }, + { + "name": "expected_mtime_ms", + "type": "Option" + }, + { + "name": "expected_content_hash", + "type": "Option" + } + ], + "ret": "Result", + "line": 3014 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_edit_text", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "old_string", + "type": "String" + }, + { + "name": "new_string", + "type": "String" + }, + { + "name": "expected_replacements", + "type": "Option" + }, + { + "name": "replace_all", + "type": "Option" + }, + { + "name": "expected_mtime_ms", + "type": "Option" + }, + { + "name": "expected_content_hash", + "type": "Option" + } + ], + "ret": "Result", + "line": 3155 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_delete", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3238 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_open_workspace_path", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "mode", + "type": "Option" + } + ], + "ret": "Result", + "line": 3362 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_create_dir", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3414 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_rename", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "from_path", + "type": "String" + }, + { + "name": "to_path", + "type": "String" + } + ], + "ret": "Result", + "line": 3492 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_roots", + "is_async": true, + "params": [], + "ret": "Result", + "line": 3614 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_list_dirs", + "is_async": true, + "params": [ + { + "name": "path", + "type": "String" + }, + { + "name": "max_results", + "type": "Option" + } + ], + "ret": "Result", + "line": 3679 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_list", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "Option" + }, + { + "name": "depth", + "type": "Option" + }, + { + "name": "offset", + "type": "Option" + }, + { + "name": "max_results", + "type": "Option" + }, + { + "name": "show_hidden", + "type": "Option" + } + ], + "ret": "Result", + "line": 3869 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_glob", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "Option" + }, + { + "name": "pattern", + "type": "String" + }, + { + "name": "offset", + "type": "Option" + }, + { + "name": "max_results", + "type": "Option" + }, + { + "name": "sort_by", + "type": "Option" + } + ], + "ret": "Result", + "line": 4009 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_grep", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "Option" + }, + { + "name": "pattern", + "type": "String" + }, + { + "name": "file_pattern", + "type": "Option" + }, + { + "name": "ignore_case", + "type": "Option" + }, + { + "name": "output_mode", + "type": "Option" + }, + { + "name": "head_limit", + "type": "Option" + }, + { + "name": "offset", + "type": "Option" + }, + { + "name": "context", + "type": "Option" + }, + { + "name": "multiline", + "type": "Option" + } + ], + "ret": "Result", + "line": 4329 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/fs.rs", + "module": "fs", + "name": "fs_mention_list", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "max_results", + "type": "Option" + }, + { + "name": "query", + "type": "Option" + }, + { + "name": "show_hidden", + "type": "Option" + } + ], + "ret": "Result", + "line": 4549 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_status", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3136 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_discover_repositories", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3143 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_branches", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3150 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_switch_branch", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "branch", + "type": "String" + }, + { + "name": "kind", + "type": "Option" + } + ], + "ret": "Result", + "line": 3157 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_create_branch", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "branch", + "type": "String" + }, + { + "name": "start_point", + "type": "Option" + } + ], + "ret": "Result", + "line": 3168 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_init", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "branch", + "type": "Option" + }, + { + "name": "user_name", + "type": "Option" + }, + { + "name": "user_email", + "type": "Option" + } + ], + "ret": "Result", + "line": 3181 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_clone_repository", + "is_async": true, + "params": [ + { + "name": "parent", + "type": "String" + }, + { + "name": "name", + "type": "String" + }, + { + "name": "remote_url", + "type": "String" + }, + { + "name": "branch", + "type": "Option" + } + ], + "ret": "Result", + "line": 3200 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_clone_repository_start", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "parent", + "type": "String" + }, + { + "name": "name", + "type": "String" + }, + { + "name": "remote_url", + "type": "String" + }, + { + "name": "branch", + "type": "Option" + } + ], + "ret": "Result", + "line": 3214 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_clone_repository_tasks", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "Result, String>", + "line": 3225 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_clone_repository_cancel", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "task_id", + "type": "String" + } + ], + "ret": "Result", + "line": 3232 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_clone_repository_dismiss", + "is_async": false, + "params": [ + { + "name": "registry", + "type": "tauri::State<'_, Arc>" + }, + { + "name": "task_id", + "type": "String" + } + ], + "ret": "Result, String>", + "line": 3240 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_list_remote_branches", + "is_async": true, + "params": [ + { + "name": "remote_url", + "type": "String" + } + ], + "ret": "Result", + "line": 3249 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_diff", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "mode", + "type": "Option" + }, + { + "name": "path", + "type": "Option" + } + ], + "ret": "Result", + "line": 3258 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_log", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "limit", + "type": "Option" + }, + { + "name": "skip", + "type": "Option" + } + ], + "ret": "Result", + "line": 3269 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_commit_details", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "commit", + "type": "String" + } + ], + "ret": "Result", + "line": 3280 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_compare_commit_with_remote", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "commit", + "type": "String" + } + ], + "ret": "Result", + "line": 3290 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_commit_diff", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "commit", + "type": "String" + }, + { + "name": "path", + "type": "Option" + } + ], + "ret": "Result", + "line": 3302 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_stage", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3313 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_stage_all", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3320 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_unstage", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3327 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_unstage_all", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3334 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_discard", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + }, + { + "name": "old_path", + "type": "Option" + } + ], + "ret": "Result", + "line": 3341 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_discard_all", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3352 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_add_to_gitignore", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3359 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_open_system_file_location", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "path", + "type": "String" + } + ], + "ret": "Result", + "line": 3369 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_commit", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "message", + "type": "String" + } + ], + "ret": "Result", + "line": 3379 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_fetch", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3386 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_pull", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3393 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_set_remote", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "remote_url", + "type": "String" + } + ], + "ret": "Result", + "line": 3400 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_push", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3410 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_delete_branch", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "branch", + "type": "String" + }, + { + "name": "force", + "type": "Option" + } + ], + "ret": "Result", + "line": 3417 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_rename_branch", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "branch", + "type": "String" + }, + { + "name": "new_branch", + "type": "String" + } + ], + "ret": "Result", + "line": 3428 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_stash_push", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + }, + { + "name": "message", + "type": "Option" + } + ], + "ret": "Result", + "line": 3441 + }, + { + "attr": "#[tauri::command(rename_all = \"snake_case\")]", + "file": "workspace/git.rs", + "module": "git", + "name": "git_stash_pop", + "is_async": true, + "params": [ + { + "name": "workdir", + "type": "String" + } + ], + "ret": "Result", + "line": 3451 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/subagent_worktree.rs", + "module": "subagent_worktree", + "name": "subagent_worktree_create", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentWorktreeCreateInput" + } + ], + "ret": "Result", + "line": 1052 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/subagent_worktree.rs", + "module": "subagent_worktree", + "name": "subagent_worktree_status", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentWorktreeStatusInput" + } + ], + "ret": "Result", + "line": 1142 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/subagent_worktree.rs", + "module": "subagent_worktree", + "name": "subagent_worktree_apply", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentWorktreeApplyInput" + } + ], + "ret": "Result", + "line": 1153 + }, + { + "attr": "#[tauri::command]", + "file": "workspace/subagent_worktree.rs", + "module": "subagent_worktree", + "name": "subagent_worktree_cleanup", + "is_async": true, + "params": [ + { + "name": "input", + "type": "SubagentWorktreeCleanupInput" + } + ], + "ret": "Result", + "line": 1164 + }, + { + "attr": "#[tauri::command]", + "file": "../services/proxy.rs", + "module": "proxy", + "name": "proxy_get_server_info", + "is_async": false, + "params": [ + { + "name": "state", + "type": "tauri::State<'_, Arc>" + } + ], + "ret": "ProxyServerInfo", + "line": 79, + "is_service": true + } +] \ No newline at end of file diff --git a/scripts/verify_headless.py b/scripts/verify_headless.py new file mode 100644 index 000000000..76e578dab --- /dev/null +++ b/scripts/verify_headless.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Verify headless.rs dispatch coverage against the committed command manifest. + +The command manifest (scripts/manifest/commands.json) is the source of truth +for the Tauri command surface. This script asserts that: + + 1. every manifest command has a dispatch arm in src/headless.rs + 2. every dispatch arm is backed by a manifest command + +This is the CI guard that catches "new command added but headless dispatch not +updated" — the historical drift failure mode of the headless build. + +Usage: + python3 scripts/verify_headless.py [--manifest scripts/manifest/commands.json] [--headless ] +""" +import os, re, sys, argparse, json + +REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +DEFAULT_MANIFEST = os.path.join(os.path.dirname(os.path.abspath(__file__)), "manifest", "commands.json") +DEFAULT_HEADLESS = os.path.join(REPO, "crates/agent-gui/src-tauri/src/headless.rs") + +ap = argparse.ArgumentParser() +ap.add_argument("--manifest", default=DEFAULT_MANIFEST) +ap.add_argument("--headless", default=DEFAULT_HEADLESS) +args = ap.parse_args() + +cmds = json.load(open(args.manifest)) + +with open(args.headless) as f: + headless = f.read() + +ARM_RE = re.compile(r'^\s*"([a-z0-9_]+)"\s*=>', re.M) +arms = set(ARM_RE.findall(headless)) + +expected = set(c["name"] for c in cmds) + +missing = sorted(expected - arms) +extra = sorted(arms - expected) + +ok = True +if missing: + print(f"MISSING dispatch arms in headless.rs ({len(missing)}):") + for m in missing: + print(f" - {m}") + ok = False +if extra: + print(f"EXTRA dispatch arms not backed by a manifest command ({len(extra)}):") + for e in extra: + print(f" - {e}") + ok = False + +print(f"manifest commands: {len(expected)}, dispatch arms: {len(arms)}") +if ok: + print("OK: dispatch coverage matches the command manifest.") + sys.exit(0) +sys.exit(1)