diff --git a/.github/tauri-cef-expected-sha b/.github/tauri-cef-expected-sha index fc80ee845c..0bc836bb06 100644 --- a/.github/tauri-cef-expected-sha +++ b/.github/tauri-cef-expected-sha @@ -1 +1 @@ -5ec3d8836cbae300e36b7a9c0d302a65de92c58d +11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 124b0f43c2..734c048912 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -287,13 +287,21 @@ jobs: mkdir -p "$OPENHUMAN_WORKSPACE" bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + - name: Pack Playwright E2E failure artifacts + if: failure() + run: | + mkdir -p .ci/artifacts + tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \ + -C "$OPENHUMAN_WORKSPACE" . + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + - name: Upload Playwright E2E failure artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} - path: | - ${{ runner.temp }}/openhuman-playwright-workspace/** + path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4ae6ae2029..4e2afa1c88 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -443,9 +443,11 @@ jobs: set -euo pipefail EXPECTED=$(cat <<'EOF' core/all_tests.rs + core/autocomplete_cli_adapter.rs core/cli_tests.rs core/jsonrpc_tests.rs core/legacy_aliases.rs + core/runtime/context.rs openhuman/agent/harness/builtin_definitions.rs openhuman/agent/harness/definition_tests.rs openhuman/agent/harness/session/tests.rs @@ -462,7 +464,7 @@ jobs: openhuman/x402/stub.rs EOF ) - ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows)"' src --include='*.rs' \ + ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|channels|desktop-automation)"' src --include='*.rs' \ | xargs grep -lE '#\[test\]|#\[tokio::test\]|fn .*_test' 2>/dev/null | sed 's|^src/||' | sort -u) if ! diff <(echo "$EXPECTED" | sed 's/^ *//' | sort -u) <(echo "$ACTUAL"); then echo "::error::Gated-test file set changed. Update the EXPECTED allowlist in the rust-feature-gate-smoke lane, and extend the scoped 'cargo test' filter if the new module can carry an ungated-assert regression (see #5022)." @@ -470,6 +472,54 @@ jobs: fi echo "gate-contract test coverage allowlist is current" + # Report-only (#5046). Measures steady-state RSS of an embedded openhuman_core + # agent roster and uploads the raw samples + a human summary. Deliberately NOT + # in `pr-ci-gate.needs`, so it can never fail a PR — it exists to accrue a + # baseline and its runner variance before the 30 MiB gate is flipped to + # blocking in a follow-up (add this job to pr-ci-gate + a threshold step then). + rust-rss-bench: + name: Rust RSS Benchmark (report-only) + needs: [changes] + if: needs.changes.outputs['rust-core'] == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 40 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + cache-on-failure: true + shared-key: pr-rust-rss-bench + + # Fixture-contract signal: the gated bin's unit tests (build_roster, + # warm-up turn) never enter the default coverage lane, so run them here. + - name: Run rss-bench fixture tests + run: bash scripts/ci-cancel-aware.sh cargo test --features rss-bench --bin rss-bench + + - name: Build stripped-release rss-bench + run: bash scripts/ci-cancel-aware.sh cargo build --release --features rss-bench --bin rss-bench + + - name: Measure embedded RSS (5 fresh procs x {1,8} agents) + run: ./target/release/rss-bench --out bench-rss.json | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload raw RSS samples + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rss-bench-report + path: bench-rss.json + rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality] @@ -806,7 +856,7 @@ jobs: key: tinycortex - name: Run TinyCortex engine and Composio sync tests - run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync + run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync,persona pr-ci-gate: name: PR CI Gate diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index b99adf6484..1ee21ff721 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -26,7 +26,10 @@ jobs: runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 30 + # The complete serial web suite currently takes about 45 minutes on the + # shared runner; keep the standalone diagnostic workflow aligned with the + # 90-minute budget used by CI Full. + timeout-minutes: 90 steps: - name: Checkout code uses: actions/checkout@v7 @@ -73,12 +76,20 @@ jobs: mkdir -p "$OPENHUMAN_WORKSPACE" bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + - name: Pack Playwright E2E failure artifacts + if: failure() + run: | + mkdir -p .ci/artifacts + tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \ + -C "$OPENHUMAN_WORKSPACE" . + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + - name: Upload Playwright E2E failure artifacts if: failure() uses: actions/upload-artifact@v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} - path: | - ${{ runner.temp }}/openhuman-playwright-workspace/** + path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 2e0432060a..85254b6878 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -291,7 +291,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 @@ -765,7 +766,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 @@ -976,7 +978,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index b5dfc2b822..df79dac508 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +worktrees/* +!worktrees/.gitkeep + # Workflow docs (local only) workflow create_issue @@ -129,4 +132,6 @@ distribution.cer # Release note previews CHANGELOG.preview.md *.profraw -*.diff \ No newline at end of file +*.diff + +.claude/worktrees/ diff --git a/.husky/pre-push b/.husky/pre-push index d39768b34a..8cbf5b3b49 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,16 +1,26 @@ #!/usr/bin/env sh -# Bail out immediately on Ctrl+C / SIGTERM. Without this trap, an interrupt -# only kills the current pnpm subprocess; the script then captures its 130 -# exit, mistakes it for a normal failure, and runs the next pnpm step. +# Bail out immediately on Ctrl+C / SIGTERM. abort() { + EXIT_CODE="$1" echo echo "Pre-push aborted." - trap - INT TERM - kill -- -$$ 2>/dev/null - exit 130 + exit "$EXIT_CODE" +} +trap 'abort 130' INT +trap 'abort 143' TERM + +# Commands run synchronously in the hook's foreground process group, so Ctrl+C +# reaches pnpm and its children. Do not mistake the resulting exit code for a +# normal check failure and continue with the next command. +run_check() { + "$@" + CHECK_EXIT=$? + case "$CHECK_EXIT" in + 130|143) abort "$CHECK_EXIT" ;; + esac + return "$CHECK_EXIT" } -trap abort INT TERM # Windows Git Bash can miss Node/Pnpm in PATH when hooks run. # Recover from common PATH drift by hydrating from where.exe. @@ -65,7 +75,7 @@ fi # Run format check first (capture exit code without breaking script) set +e -pnpm format:check +run_check pnpm format:check FORMAT_EXIT=$? set -e @@ -77,7 +87,7 @@ fi # Run lint check (capture exit code without breaking script) set +e -pnpm lint +run_check pnpm lint LINT_EXIT=$? set -e @@ -89,19 +99,19 @@ fi # Run TypeScript compile check (capture exit code without breaking script) set +e -pnpm compile +run_check pnpm compile COMPILE_EXIT=$? set -e # Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:clippy +run_check pnpm rust:clippy RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ set +e -pnpm --dir app run lint:commands-tokens +run_check pnpm --dir app run lint:commands-tokens CMD_TOKENS_EXIT=$? set -e diff --git a/AGENTS.md b/AGENTS.md index 0bb6a8daa6..c29e73a176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,8 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | | `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | `openhuman::mcp_server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp_registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp_audit` (write-audit log), and the static config-declared server set in `openhuman::mcp_client`. ~19 agent tools, ~20k LOC | **none** (see scope note) | +| `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | +| `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **none** (`tinychannels` is load-bearing) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. @@ -306,6 +308,29 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a `src/core/all.rs` needs **no** `#[cfg]` for this gate: the stub aggregators return empty vecs, so the registration sites keep compiling unchanged. +#### The `tui` gate + +The tabbed terminal UI (`openhuman`, or explicitly `openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`). Bare-command auto-launch requires terminal stdin/stdout and `HostKind::Cli`; Docker, CI, pipes, and `--no-tui` retain the non-TUI CLI path. + +- **The `"tui" | "chat"` CLI arm in `src/core/cli.rs` is un-`#[cfg]`'d on purpose.** In a slim build it resolves to `tui::stub::run_from_cli`, which bails with the disabled-error rather than falling through to `unknown namespace: tui` (which reads like a typo, not a build fact). Same reasoning as the `mcp` arm. Pinned by `tui_subcommand_reports_disabled_build_when_gate_off` / `chat_alias_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs` (both `#[cfg(not(feature = "tui"))]`). `"tui" | "chat"` is also added to the banner-suppression `matches!` (a TUI owns the terminal — a banner would corrupt it). +- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of existing registered controllers — it boots the core in-process (`CoreBuilder::new(HostKind::detect_standalone()).domains(DomainSet::full()).services(ServiceSet::none())`), sends chat turns through `web_chat`, reads a bounded in-memory copy of the file-only core log stream, edits only curated safe config getters/updaters, and invokes auth controllers for account/status actions. Never render `config.get` wholesale because the full snapshot can contain secrets. +- **Terminal hygiene is load-bearing.** `logging::init_for_tui` installs a **file-only** subscriber (never stderr) — a single core boot log on stdout/stderr would corrupt the alternate-screen UI. `terminal::TerminalGuard` restores raw mode + the main screen on `Drop`, and a panic hook chains a restore ahead of the default hook. All `[tui]` state-transition logs go to the file, never `println!`. +- **Intentionally NOT forwarded to the desktop shell** (the app ships its own Tauri UI). It carries the only current entry in `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`; the pure reducer lives in `src/openhuman/tui/state.rs` (`TranscriptState::apply_event`) with unit tests, so most behaviour is testable without a terminal. + +Drops the exclusive `ratatui` + `crossterm` deps when off. Verify with `cargo tree -i ratatui --no-default-features --features tokenjuice-treesitter` (must return nothing). +#### The `channels` gate (#4801 — last child of #4795) + +Leaf-gate pattern with **two ungated carve-outs and no stub file** — the reach-map put every gated symbol at a *registration/leaf* call site, so absence (unknown-method / omitted tool), not a disabled-error stub, is the correct off-state (same rationale as `flows` / `meet`). + +- **Sheds ZERO dependencies — do NOT re-litigate.** `tinychannels` stays always-compiled regardless of the gate: `config/schema/channels.rs` re-exports its config types, `event_bus/events.rs`'s `DomainEvent` embeds `tinychannels::ChannelInboundEnvelope`, and `security/pairing.rs` re-exports its pairing helpers. The `channels = []` feature list is intentionally empty. The gate's value is compile-time surface + binary size. (`whatsapp-web` is a **refinement inside** the gate — `whatsapp-web = ["channels", "tinychannels/whatsapp-web"]`.) +- **Two ungated carve-outs.** `pub mod traits;` (a one-line `tinychannels` `Channel`/`SendMessage` re-export) and `pub mod cli;` (`CliChannel`, a dependency-free local stdin/stdout REPL) stay compiled in **all** builds — both are reached by the always-on agent-harness interactive loop (`agent::harness::session::runtime::run_interactive`). Same shape as the `meet_agent::wav` carve-out. `channels::mod.rs` `#[cfg(feature = "channels")]`s everything else; nothing inside the gated submodules changes. +- **The in-app web chat is NOT gated.** `openhuman::web_chat` (RPC namespace `channel`, decoupled from `channels/` in #5002 + #5003 which also moved `learning` out) is core product surface and stays always-compiled even though its runtime tag is `DomainGroup::Channels`. Its registration push in `src/core/all.rs` is deliberately left ungated; the both-ways test pins `channel` present with the feature OFF. +- **Three mis-housed imports were retargeted to `tinychannels` (no stub needed).** `cron/bus.rs` (`Channel`/`SendMessage`/`ChannelMessage`), `memory_conversations/bus.rs` (`ChannelMessage` + `context::conversation_history_key`), and `audio_toolkit/ops.rs` (`providers::email_channel::EmailChannel`) reached the gated domain only to pick up symbols that actually live in `tinychannels`; pointing them straight at the crate removes the always-on → gated edge (and the voice→channels cross-gate edge). The old `channels::` paths were 1-line delegations / `pub use` re-exports of exactly these. +- **Leaf-gated call sites** (each carries its own `#[cfg]`): the 5 controller-registration pushes in `src/core/all.rs` (channels controllers, `webview_apis`, `webview_notifications`, public + internal `whatsapp_data`), the `ChannelInboundSubscriber` + web-only-proactive block in `src/core/jsonrpc.rs`, `spawn_channels_service` in `src/core/runtime/services.rs`, the `whatsapp_data::global::init` block in `src/core/runtime/context.rs`, and the `whatsapp_data::tools::*` glob + 3 `WhatsAppData*Tool` registrations in `src/openhuman/tools/{mod,ops}.rs`. The `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` `pub mod` declarations in `src/openhuman/mod.rs` are leaf-gated too. String-match arms (`"channels" =>` descriptions, `whatsapp_data_` in `group_for_namespace`) stay **ungated** — they are data. +- **`start_bootstrap_jobs`' `services.channels` block keeps running slim** — it drives composio sync / workspace-memory sync / orchestration drain and names **no** `channels::` symbol, so it stays ungated by design. +- **No CLI change.** There is no `openhuman channels` subcommand; generic namespace resolution yields "unknown namespace" when off (the `flows` precedent — acceptable). +- **Both-ways tests.** `channels_controllers_{registered_when_feature_on,absent_when_feature_off}` in `src/core/all_tests.rs` pin the controller surface (the OFF half also asserts `channel`/web_chat survives), and `whatsapp_data_tools_{present_when_channels_on,absent_when_channels_off}` in `src/openhuman/tools/ops_tests.rs` pin the 3 agent tools (that module has the full-tool-list machinery). CI's smoke lane runs `cargo check` only, so run `cargo test --lib --no-default-features --features tokenjuice-treesitter core::all::tests` locally after touching any gated surface. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..d49bdf0ef3 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,27 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it using these metadata." +title: "OpenHuman" +type: "software" +authors: + - name: "tinyhumansai" + - name: "OpenHuman Contributors" +repository-code: "https://github.com/tinyhumansai/openhuman" +url: "https://github.com/tinyhumansai/openhuman" +abstract: "OpenHuman is an open‑source AI platform for building and orchestrating autonomous agents and workflows." +license: "GPL-3.0" +version: "0.63.1" +date-released: "2026-07-21" +keywords: + - "ai" + - "agents" + - "workflows" + - "orchestration" + - "openhuman" +preferred-citation: + type: "software" + title: "OpenHuman" + authors: + - name: "tinyhumansai" + year: "2026" + url: "https://github.com/tinyhumansai/openhuman" + version: "0.63.1" diff --git a/Cargo.lock b/Cargo.lock index 0f41c1de1f..bfdee024d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -91,7 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -171,6 +177,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -314,6 +329,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -500,7 +524,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -509,20 +533,35 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex", "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -585,9 +624,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -688,6 +727,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -720,9 +765,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -747,6 +792,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -904,15 +958,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -979,7 +1024,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -994,7 +1039,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1044,6 +1089,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1152,6 +1211,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.16.2" @@ -1263,7 +1331,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1276,7 +1344,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1366,6 +1434,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.12.1" @@ -1392,6 +1466,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1430,6 +1531,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1475,6 +1586,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1515,6 +1660,12 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -1580,6 +1731,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1587,16 +1739,19 @@ dependencies = [ ] [[package]] -name = "dialoguer" -version = "0.12.0" +name = "dhat" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", + "backtrace", + "lazy_static", + "mintex", + "parking_lot", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", ] [[package]] @@ -1620,7 +1775,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -1680,7 +1834,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -1962,7 +2116,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2042,7 +2196,7 @@ dependencies = [ "rlp", "serde", "serde_json", - "strum", + "strum 0.26.3", "tempfile", "thiserror 1.0.69", "tiny-keccak", @@ -2077,6 +2231,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -2104,12 +2267,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2122,13 +2279,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -2155,6 +2322,12 @@ dependencies = [ "webdriver", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2192,6 +2365,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2220,6 +2404,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2232,6 +2422,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2434,15 +2630,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2482,7 +2669,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2555,7 +2742,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -2623,6 +2810,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2631,6 +2820,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -2698,7 +2892,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -2710,15 +2904,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -2894,7 +3079,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -3068,6 +3253,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3166,6 +3357,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3176,6 +3376,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3381,6 +3594,17 @@ dependencies = [ "signature", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.1.6" @@ -3420,6 +3644,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "landlock" version = "0.4.4" @@ -3538,13 +3768,22 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-keyutils" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", ] @@ -3594,7 +3833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.1", "cbc", "ecb", "encoding_rs", @@ -3603,7 +3842,7 @@ dependencies = [ "indexmap", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -3615,6 +3854,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3632,6 +3880,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3652,9 +3910,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", ] @@ -3693,16 +3951,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "md5" version = "0.8.0" @@ -3725,7 +3973,22 @@ dependencies = [ ] [[package]] -name = "mime" +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" @@ -3756,6 +4019,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "mio" version = "1.2.0" @@ -3763,7 +4032,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "log", + "wasi", "windows-sys 0.61.2", ] @@ -3822,7 +4092,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3845,13 +4115,26 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4001,6 +4284,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -4041,7 +4333,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4057,7 +4349,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4069,7 +4361,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4091,7 +4383,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4113,7 +4405,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -4124,7 +4416,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4169,7 +4461,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -4187,7 +4479,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4199,7 +4491,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4212,7 +4504,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4223,7 +4515,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4235,7 +4527,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4248,28 +4540,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -4374,14 +4657,13 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.6" +version = "0.63.1" dependencies = [ "aes-gcm", "aho-corasick", "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -4393,13 +4675,13 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", + "crossterm", "curve25519-dalek", - "dialoguer", + "dhat", "directories", "dirs 5.0.1", "docx-rs", @@ -4419,7 +4701,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -4429,29 +4711,22 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", "proptest", - "prost", "rand 0.10.1", + "ratatui", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rppal", "rusqlite", "rustls", @@ -4464,7 +4739,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -4472,7 +4746,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents", "tinychannels", "tinycortex", "tinyflows", @@ -4489,13 +4763,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", - "tree-sitter", - "tree-sitter-python", - "tree-sitter-rust", - "tree-sitter-typescript", "uiautomation", - "unicode-normalization", - "unicode-segmentation", "unicode-width", "url", "urlencoding", @@ -4518,7 +4786,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4556,80 +4824,20 @@ dependencies = [ ] [[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http 1.4.0", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http 1.4.0", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "opentelemetry_sdk" -version = "0.32.0" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", + "num-traits", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "os_info" version = "3.14.0" @@ -4638,7 +4846,7 @@ checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.30.1", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -4652,6 +4860,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4738,7 +4970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -4750,7 +4982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -4762,7 +4994,7 @@ dependencies = [ "adobe-cmap-parser", "cff-parser", "encoding_rs", - "euclid", + "euclid 0.20.14", "log", "lopdf", "postscript", @@ -4776,17 +5008,69 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -4803,7 +5087,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_shared 0.13.1", - "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -4812,10 +5105,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4826,6 +5129,28 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -4911,7 +5236,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -4973,50 +5298,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -5106,29 +5387,15 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags 2.11.1", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5194,7 +5461,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -5255,7 +5522,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -5276,7 +5543,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -5424,6 +5691,107 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rdev" version = "0.5.3" @@ -5446,7 +5814,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -5613,7 +5981,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -5622,7 +5989,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -5633,7 +6000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" dependencies = [ "ahash", - "bitflags 2.11.1", + "bitflags 2.13.1", "no-std-compat", "num-traits", "once_cell", @@ -5725,8 +6092,8 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ - "bitflags 2.11.1", - "fallible-iterator 0.3.0", + "bitflags 2.13.1", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -5740,6 +6107,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5767,7 +6140,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -5963,7 +6336,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -6009,7 +6382,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys 0.8.7", "libc", @@ -6022,7 +6395,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys 0.8.7", "libc", @@ -6135,7 +6508,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "sentry-backtrace", "sentry-core", "tracing-core", @@ -6341,25 +6714,31 @@ dependencies = [ ] [[package]] -name = "shell-words" -version = "1.1.1" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "shellexpand" -version = "3.1.2" +name = "signal-hook" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ - "dirs 6.0.0", + "libc", + "signal-hook-registry", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "signal-hook-mio" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] [[package]] name = "signal-hook-registry" @@ -6533,7 +6912,7 @@ dependencies = [ "lazycell", "libc", "mach2 0.5.0", - "nix", + "nix 0.30.1", "num-traits", "plist", "uom", @@ -6587,7 +6966,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -6603,6 +6991,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6664,7 +7064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", @@ -6694,7 +7094,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6739,6 +7139,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thin-vec" version = "0.2.18" @@ -6785,6 +7261,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.9" @@ -6816,7 +7298,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -6850,7 +7334,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.9.0" +version = "2.1.0" dependencies = [ "async-trait", "bytes", @@ -6867,23 +7351,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tinyagents" -version = "2.0.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "tinychannels" version = "0.1.0" @@ -6898,7 +7365,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -6949,7 +7416,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "tinyagents 2.0.0", + "tinyagents", "tokio", "toml 0.8.23", "tracing", @@ -6969,7 +7436,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents", "tracing", ] @@ -7010,7 +7477,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -7095,32 +7562,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7325,7 +7766,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.4.0", @@ -7563,6 +8004,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uiautomation" version = "0.25.0" @@ -7652,6 +8099,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -7796,6 +8254,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -7820,6 +8279,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wacore" version = "0.5.0" @@ -7841,7 +8309,7 @@ dependencies = [ "futures", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "md5", "once_cell", @@ -7893,7 +8361,7 @@ checksum = "127a3a6e7554ce002092f1711aa927b0efa3d3fb1ee83506525565c626e68834" dependencies = [ "flate2", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "serde", "serde_json", ] @@ -7928,7 +8396,7 @@ dependencies = [ "ghash 0.6.0", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "prost", "rand 0.10.1", @@ -8007,15 +8475,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -8034,15 +8493,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -8139,7 +8589,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -8218,6 +8668,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid 1.23.1", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid 0.22.14", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whatsapp-rust" version = "0.5.0" @@ -8303,19 +8825,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -8338,7 +8847,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -8998,7 +9507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", @@ -9306,7 +9815,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/Cargo.toml b/Cargo.toml index 135cd383a5..17575b1c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.61.6" +version = "0.63.1" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false @@ -41,6 +41,25 @@ path = "src/bin/test_mcp_stub.rs" [[bin]] name = "openhuman-fleet" path = "src/bin/fleet.rs" +# The fleet supervisor embeds the axum control-plane server, so it only builds +# with the HTTP transport compiled in (#5048). Under `--no-default-features` +# (no `http-server`) this target is skipped rather than failing to link. +required-features = ["http-server"] + +# Embedded-RSS benchmark harness (#5046). Gated behind the default-OFF +# `rss-bench` feature so no benchmark code enters the shipped build. Build with +# `cargo build --release --features rss-bench --bin rss-bench`. +[[bin]] +name = "rss-bench" +path = "src/bin/rss_bench.rs" +required-features = ["rss-bench"] + +# Stateful library profiling workloads (memory ingestion + real sub-agent +# delegation under a hermetic mock provider). Local/dev only. +[[bin]] +name = "library-profile" +path = "src/bin/library_profile/main.rs" +required-features = ["rss-bench"] [lib] name = "openhuman_core" @@ -53,9 +72,9 @@ crate-type = ["rlib"] tinyplace = "2.0" # tinyflows — host-agnostic workflow engine (typed node graph → validate → compile → # run on tinyagents). Powers the "Workflows" feature via the seam in -# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.7 transitively -# (same version openhuman already uses — no conflict). Published on crates.io -# and patched below to the vendored submodule. +# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 2.1 transitively +# (same version OpenHuman uses directly, preserving one trait identity). Published +# on crates.io and patched below to the vendored submodule. # # `mock` feature: enables `tinyflows::caps::mock::mock_capabilities()` — the # deterministic in-memory capability bundle the flows `dry_run_workflow` agent @@ -85,28 +104,27 @@ tinyjuice = { version = "0.2.1", default-features = false } # NOT enabled here — the default-ON `flows` feature turns it on via # `tinyagents/repl` (#4797), so a slim build without `flows` sheds `rhai`. # The crate itself can never be dropped: 26+ domains consume tinyagents. -tinyagents = { version = "1.7", features = ["sqlite"] } +tinyagents = { version = "2.1", features = ["sqlite"] } # TinyCortex — Rust core for the memory engine (store/chunks/tree/retrieval/ # queue/ingest/score + long tail), vendored as a git submodule and patched # below to `vendor/tinycortex`. OpenHuman's memory subsystem migrates onto this # crate through the adapter seam in `src/openhuman/tinycortex/` (mirroring the -# tinyagents seam): engine logic in the crate; RPC, agent tools, live sync, -# security gating, and the global singleton stay host-side. rusqlite/git2 are +# tinyagents seam): engine logic (including provider sync pipelines) in the +# crate; RPC, agent tools, sync scheduling/credentials/events, security gating, +# and the global singleton stay host-side. rusqlite/git2 are # aligned to the host pins (=0.40 / 0.21) so one bundled SQLite + one libgit2 -# link. Keep the version pin in lockstep with the submodule tag. +# link. The submodule intentionally tracks reviewed upstream main commits; +# keep this semver requirement compatible with the vendored crate version. tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] } tinychannels = { version = "0.1", features = ["relay-websocket"] } -# TokenJuice code compressor — AST-aware signature extraction. Optional (C build) -# behind the default `tokenjuice-treesitter` feature; disabling it falls back to -# the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs. -tree-sitter = { version = "0.26", optional = true } -tree-sitter-rust = { version = "0.24", optional = true } -tree-sitter-typescript = { version = "0.23", optional = true } -tree-sitter-python = { version = "0.25", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" serde_yaml = "0.9" +# dhat — heap profiler for the offline `library-profile` benchmark binary only. +# Default-OFF, dev-only: pulled in solely by the `rss-bench-dhat` feature so it +# never enters the shipped desktop/library build (same posture as `rss-bench`). +dhat = { version = "0.3", optional = true } # (Removed `html2md` dep. dhat-rs profiling on real Gmail inboxes # showed `html2md::walk` and `html2md::tables::handle` allocating # ~894 MB peak heap on a 10 KB HTML input from Otter.ai-style emails @@ -117,7 +135,15 @@ serde_yaml = "0.9" # stripper (`fast_html_to_text` in # providers/gmail/post_process.rs) and prefer the email's # `text/plain` MIME part when available.) -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] } +# TLS: rustls only in the base declaration, so Linux/macOS — including the +# headless embedding target (#5046) — link a single TLS stack + cert set +# (Mozilla webpki-roots). Windows re-adds `native-tls` via the +# `[target.'cfg(windows)'.dependencies]` block below (Cargo unions features +# per target), because `src/openhuman/tls/mod.rs` deliberately routes the +# Windows client through the SChannel/OS cert store for corporate MITM + +# AV root CAs. So the two TLS backends coexist only on Windows, never on +# the RAM-sensitive platforms. +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] } # Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes` # can return `bytes::Bytes` without a copy. bytes = "1" @@ -180,21 +206,16 @@ cron = "0.12" futures-util = "0.3" directories = "6" toml = "1.0" -shellexpand = "3.1" schemars = "1.2" tracing = { version = "0.1", default-features = false } tracing-log = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] } tracing-appender = "0.2" -prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" motosan-ai-oauth = { version = "0.2", features = ["codex"] } thiserror = "2.0" ring = "0.17" -prost = { version = "0.14", default-features = false } -postgres = { version = "0.19", features = ["with-chrono-0_4"] } chrono-tz = "0.10" -dialoguer = { version = "0.12", features = ["fuzzy-select"] } dotenvy = "0.15" console = "0.16" regex = "1.10" @@ -206,14 +227,6 @@ regex = "1.10" aho-corasick = "1.1" walkdir = "2" glob = "0.3" -unicode-segmentation = "1" -unicode-width = "0.2" -# NFKC + combining-mark detection for the cross-thread search inverted -# index (`memory_conversations::tokenize`). NFKC unifies CJK half/full- -# width variants and Arabic presentation forms; `canonical_combining_class` -# lets us strip diacritics across all scripts (Polish ą→a, Arabic harakat, -# Hebrew niqqud, etc.) without per-language tables. -unicode-normalization = "0.1" hostname = "0.4.2" rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" @@ -222,23 +235,23 @@ webpki-roots = "1.0.6" sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } -clap_complete = "4.5" lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } -mail-parser = "0.11.2" -async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false } -axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } -tower = { version = "0.5", default-features = false } -opentelemetry = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } -sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } +# HTTP + Socket.IO server transport — the `/rpc` JSON-RPC endpoint, `/v1` +# OpenAI-compat router, agentbox/http_host sub-servers, and the Socket.IO +# live-event bridge. Exclusive to the default-ON `http-server` feature (#5048): +# a slim/embedded build without it (`--no-default-features`) sheds `axum` + +# `socketioxide` and never binds a listener — the core still runs background +# services and answers over the CLI/native dispatch surface. See the +# `http-server` feature note below and `src/core/http_server_status.rs`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"], optional = true } +sentry = { version = "0.47.0", default-features = false, optional = true, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" -socketioxide = { version = "0.15", features = ["extensions"] } -whisper-rs = "0.16" +socketioxide = { version = "0.15", features = ["extensions"], optional = true } +whisper-rs = { version = "0.16", optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } tempfile = "3" -cpal = "0.15" +cpal = { version = "0.15", optional = true } hound = { version = "3.5", optional = true } enigo = "0.3" arboard = "3" @@ -255,11 +268,9 @@ ethers-signers = { version = "2.0.14", default-features = false } # - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). # - ed25519-dalek: Solana transaction signing. # - bs58: Solana base58 addresses + Tron base58check addresses. -# - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"], optional = true } ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } -ripemd = "0.1" # Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, # Tron secp256k1 derivation, Solana ed25519 SLIP-0010 derivation). Same crate # ethers-signers uses internally, exposed as a direct dep so we can derive @@ -269,24 +280,41 @@ coins-bip39 = "0.8" curve25519-dalek = { version = "4", default-features = false, features = ["alloc"], optional = true } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } -pdf-extract = "0.10" +pdf-extract = { version = "0.10", optional = true } # The WhatsApp Web provider (and its `whatsapp-rust` / `wacore` / `serde-big-array` # stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards # to `tinychannels/whatsapp-web`. -ppt-rs = "0.2.14" +ppt-rs = { version = "0.2.14", optional = true } +# Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON +# `tui` feature (see `[features]` below): a slim / headless build without `tui` +# drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 +# re-exports crossterm 0.29, so declaring crossterm directly at the same major +# unifies to a single crossterm in the dep graph. Only compiled into +# `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } +# Terminal column-width measurement for the `tui` chat renderer +# (`src/openhuman/tui/render.rs`); only compiled behind `#[cfg(feature = "tui")]`. +unicode-width = { version = "0.2", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively # maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the # `ppt-rs` presentation engine: synthesise bytes in-process, hand them to # the byte-agnostic artifact pipeline. `default-features` keeps the crate's # image support (unused here, but avoids a bespoke feature list drifting). -docx-rs = "0.4.20" +docx-rs = { version = "0.4.20", optional = true } [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// # connections honor the Windows cert store, including corporate CAs # installed by AV / TLS-inspection proxies. See run-dev-win.sh notes. tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "native-tls"] } +# Windows re-adds reqwest's native-tls backend (dropped from the base decl in +# `[dependencies]`) so `tls::tls_client_builder()` can call `.use_native_tls()` +# and reach the SChannel/OS cert store — same rationale as tokio-tungstenite +# above. Cargo unions these features with the base rustls decl, so on Windows +# reqwest carries both backends; Linux/macOS keep rustls only. +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } # AppContainer / process-jail backend in `openhuman::cwd_jail`. # Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: # AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, @@ -306,7 +334,7 @@ windows-sys = { version = "0.61", features = [ # Microsoft UI Automation (UIA) bindings — the Windows backend for the # `ax_interact` tool (`accessibility::uia_interact`). Safe Rust wrappers over # the UIA COM API; the Windows analogue of the macOS AXUIElement Swift helper. -uiautomation = "0.25" +uiautomation = { version = "0.25", optional = true } [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical @@ -314,7 +342,7 @@ uiautomation = "0.25" tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] } [target.'cfg(target_os = "macos")'.dependencies] -whisper-rs = { version = "0.16", features = ["metal"] } +whisper-rs = { version = "0.16", features = ["metal"], optional = true } # Contacts framework bindings for address book seeding. objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"] } @@ -333,6 +361,18 @@ rppal = { version = "0.22", optional = true } # crates we never use (and that bloat the dev Cargo.lock noticeably). # TestTransport only needs the `test` feature. sentry = { version = "0.47.0", default-features = false, features = ["test"] } +# axum is optional in `[dependencies]` (exclusive to the default-ON +# `http-server` feature, #5048), but ~17 `#[cfg(test)]` modules stand up +# in-process axum mock servers / call `build_core_http_router` regardless of +# the feature set under test. Declaring a plain (non-optional) dev-dep keeps +# those tests compiling in ALL test builds without per-file `#[cfg]` — mirrors +# the `sentry` dual-declaration above. The prod fns those tests exercise are +# still gated, so the *tests* naming them carry `#[cfg(feature = "http-server")]`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } +# `tower` was a plain runtime dep only for its re-exports in the axum server +# path; with `http-server` optional it is test-only (tower::ServiceExt::oneshot +# drives the mock routers), so it lives here as a dev-dep now. +tower = { version = "0.5", default-features = false } # Mock HTTP server for provider E2E tests (inference_provider_e2e). wiremock = "0.6" # Used in json_rpc_e2e to backdate mtime on stale lock files. @@ -347,12 +387,56 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "channels", "tui", "medulla-local"] +# HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and +# its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` +# OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file +# server (`openhuman::http_host`), the AgentBox `/run` HTTP surface +# (`agentbox::http`), the WebSocket dictation stream +# (`inference::voice::streaming`), the `openhuman text-input run` dev server, +# the MCP Streamable-HTTP transport (`mcp_server::http`, additionally gated by +# `mcp`), and the Socket.IO live-event bridge (`core::socketio`). Default-ON — +# the desktop shell REQUIRES it (see the `HTTP_SERVER_COMPILED_IN` compile +# assert in `app/src-tauri/src/lib.rs`). Slim / headless-embedding builds opt +# out via `--no-default-features --features ""`, which drops the exclusive `axum` + `socketioxide` deps; the +# core then runs background services without binding a listener (`serve()` +# returns early) and is driven over the CLI / native dispatch surface instead. +# +# TYPE CARVE-OUT (see AGENTS.md): `core::socketio`'s inert event payload types +# (`WebChannelEvent`, `TurnUsagePayload`, `SubagentUsagePayload`, +# `SubagentProgressDetail`) stay compiled in BOTH builds — ~10 always-on +# domains construct them — so `pub mod socketio;` is UNGATED and only the +# socketioxide/axum-touching bodies are gated. Likewise `inference::http::types` +# and `EXTERNAL_OPENAI_COMPAT_PROVIDER` stay compiled for `core::auth`. +http-server = ["dep:axum", "dep:socketioxide"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ "tinyjuice/tinyjuice-treesitter", ] +# In-process inference engine: the whisper.cpp STT engine +# (`inference::local::service::whisper_engine`) plus the `cpal` audio-device +# probe shared by voice capture and the accessibility microphone-permission +# check. Default-ON. Slim / headless builds opt out via +# `--no-default-features --features ""`, which +# drops the exclusive `whisper-rs` (including the macOS `metal` variant and the +# `whisper-rs-sys` patch, which go inert once whisper-rs leaves the graph) and +# `cpal` dependencies. When off, the whisper facade resolves to +# `whisper_engine::stub` — in-process STT returns a disabled error, while the +# local-AI service still reaches Ollama / LM Studio over HTTP — and the +# microphone probe reports `Unknown`. `voice` requires this gate, so building +# `voice` always pulls `inference` in transitively. +inference = ["dep:whisper-rs", "dep:cpal"] +# Office-document tools: the `generate_presentation` (ppt-rs) and +# `generate_document` (docx-rs) agent tools, plus `pdf-extract` for PDF text +# extraction during multimodal file ingest. Default-ON. Slim / headless builds +# opt out via `--no-default-features --features ""`, which drops all three crates. Leaf gate (no stub facade): when +# off, the two tools are absent from the tool list rather than degraded to an +# error, and PDF ingest degrades the file to a reference instead of extracted +# text (`agent::multimodal::extract_pdf_text`). +documents = ["dep:pdf-extract", "dep:ppt-rs", "dep:docx-rs"] # Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation # server, always-on listening, and podcast audio generation/email delivery. # Default-ON — the desktop app always ships with voice. Slim / headless builds @@ -360,10 +444,9 @@ tokenjuice-treesitter = [ # which also drops the exclusive `hound` (WAV I/O) + `lettre` (podcast email) # dependencies. Composes with the runtime `DomainSet::voice` flag (#4796): the # feature narrows the compile-time surface, `DomainSet` gates it at runtime. -# NOTE: this gate does NOT drop whisper-rs / llama / cpal — those live in the -# inference domain (shared with accessibility for cpal) and await a separate -# `inference` gate. -voice = ["dep:hound", "dep:lettre"] +# Requires `inference` (the whisper engine + the cpal capture stack), so +# enabling `voice` turns `inference` on transitively. +voice = ["inference", "dep:hound", "dep:lettre"] # Web3 domains: openhuman::wallet + openhuman::web3 + openhuman::x402 — the # crypto wallet (multi-chain sign/broadcast), the high-level swap/bridge/dapp # surface, and the x402 machine-payment protocol. Default-ON — the desktop app @@ -458,6 +541,110 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] +# Sentry crash/error reporting: the `sentry::init` guard + secret-scrubbing +# `before_send` hook (src/main.rs), the sentry-tracing bridge layer +# (core::logging), the ~24 `before_send` Event classifiers + the two +# `report_*_message` capture paths (core::observability), the session-boundary +# scope binding (credentials::sentry_scope), and the `openhuman sentry-test` +# CLI probe. Default-ON — the desktop app always ships with crash reporting. +# Slim / headless builds opt out via `--no-default-features --features +# ""`, which drops the exclusive +# `sentry` dependency (and its transitive backtrace/contexts/panic/tracing/ +# debug-images/reqwest/rustls stack) from the non-test build. +# +# TYPE CARVE-OUT (see AGENTS.md "Compile-time domain gates"): the sentry-free +# string classifiers (`is_transient_message_failure`, +# `is_insufficient_credits_message`, `contains_transient_transport_phrase`, …) +# and the `report_*_message` / `sentry_scope::{bind,clear}` signatures stay +# compiled in BOTH builds — only the `sentry::`-touching bodies are gated — so +# NO stub file is needed and always-on callers need no per-call `#[cfg]`. When +# off, `report_error_message`/`report_warning_message` still emit their +# `tracing::{error,warn}!` diagnostic, `sentry_tracing_layer()` degrades to a +# no-op `Identity` layer, and `openhuman sentry-test` returns a "built without +# the crash-reporting feature" error. +crash-reporting = ["dep:sentry"] + +# Desktop-automation cluster (#5049): the five modules that read/drive the local +# desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware), +# `openhuman::screen_intelligence` (capture + vision loop), `openhuman::autocomplete` +# (inline completion), `openhuman::desktop_companion` (Clicky-style loop), and the +# `computer` agent-tool family (`ax_interact` / `automate` / mouse / keyboard). +# Default-ON — the desktop app always ships with these. Slim / headless builds opt +# out via `--no-default-features --features ""`, +# which drops the exclusive `uiautomation` (Windows UI Automation COM bindings) +# dependency. Composes with the runtime `DomainSet::desktop_automation` flag: the +# feature narrows the compile-time surface, `DomainSet` gates it at runtime. +# +# CARVE-OUT: the inert type modules stay compiled in BOTH builds — +# `accessibility::types` (incl. the `GlobeHotkey*` structs), `autocomplete::types` +# (`AutocompleteStatus`), and `screen_intelligence::types` (`AccessibilityStatus`, +# `CaptureImageRefResult`) — because always-on callers (`text_input`, `voice`, +# `app_state`) name them. Only behaviour is gated; the stubs re-export the one real +# type definition. See AGENTS.md "skills gate — the type carve-out". +# +# NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the +# `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here. +desktop-automation = ["dep:uiautomation"] +# Tabbed terminal UI: the `openhuman tui` (alias `chat`) CLI subcommand, a +# ratatui/crossterm front-end for logs, orchestrator chat, curated configuration, +# and account settings. Default-ON for the standalone `openhuman-core` binary, but +# INTENTIONALLY NOT forwarded to the desktop shell (the desktop app ships its own +# Tauri UI and never needs a terminal one) — see the allowlist entry in +# `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via +# `--no-default-features --features ""`, which drops +# the exclusive `ratatui` + `crossterm` dependencies. The module +# `src/openhuman/tui/` is a facade (always compiled) whose behavioural submodules +# are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a +# build-fact "tui feature disabled at compile time" error from the untouched +# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). +tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] +# Local Medulla brain (plan Flavor A, §3.1–§3.2): the `medulla_local` domain — +# a supervised `medulla-serve` Node child speaking the serve NDJSON protocol, +# host-side inference + tools port callbacks, the `medulla_local` RPC namespace, +# and the subconscious-replacement draft (`subconscious.engine = "medulla"`, +# §5.2). Default-ON — the desktop app ships it — and therefore FORWARDED to the +# Tauri shell's `openhuman_core` features (default-features=false there). Slim / +# headless builds opt out via `--no-default-features --features ""`. The module is `#[cfg(feature = "medulla-local")]` at its +# `pub mod` declaration (a registration-site gate, like `flows`); the +# `subconscious.engine` config field stays compiled in ALL builds (inert serde), +# and the subconscious tick's medulla branch is `#[cfg]`-gated so the default +# (`local`) path is byte-identical whether or not this feature is on. +# Sheds ZERO exclusive dependencies — tokio net/process, reqwest, serde are all +# load-bearing for other domains; the value is compile-time surface. +# The serve transport is unix-only (unix domain sockets): on non-unix targets +# (Windows desktop) the feature still compiles via a supervisor stub that +# reports a typed unsupported-platform error, so forwarding the default-ON +# gate to the Tauri shell never breaks Windows packaging. +medulla-local = [] + +# Channels domain: `openhuman::channels` (external-messaging providers — Telegram, +# Discord, Slack, Signal, WhatsApp, iMessage, IRC, … — plus the channel runtime, +# controllers, host, proactive messaging and inbound dispatch) together with the +# `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` +# webview-bridge domains. Default-ON — the desktop app always ships with +# channels. Slim / headless builds opt out via `--no-default-features --features +# ""`. Composes with the runtime +# `DomainSet::Channels` flag (#4796): this feature narrows the compile-time +# surface, `DomainSet` gates it at runtime. +# +# INTENTIONALLY EMPTY — do NOT "fix" this by adding `dep:` entries. This gate +# sheds ZERO dependencies: `tinychannels` stays load-bearing regardless +# (`config/schema/channels.rs` re-exports its config types, `event_bus/events.rs` +# `DomainEvent` embeds `tinychannels::ChannelInboundEnvelope`, and +# `security/pairing.rs` re-exports its pairing helpers), so it is always +# compiled. The gate's value is compile-time surface + binary size, not the dep +# tree. (`whatsapp-web` refines this gate — see below — forwarding to the +# `tinychannels/whatsapp-web` provider feature.) +# +# NOTE: the in-app web chat (`openhuman::web_chat`, RPC namespace `channel`) is +# NOT gated by this feature even though its runtime tag is +# `DomainGroup::Channels` — it is core product surface and stays always +# compiled (decoupled from `channels/` in #5002). The `channels::{traits, cli}` +# carve-outs also stay ungated — `traits` is a 1-line tinychannels re-export and +# `cli::CliChannel` is the dep-free local REPL used by the always-on agent +# harness interactive loop. See AGENTS.md "channels gate" + `channels/mod.rs`. +channels = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] @@ -465,11 +652,22 @@ browser-native = ["dep:fantoccini"] fantoccini = ["browser-native"] landlock = ["sandbox-landlock"] # The WhatsApp Web provider now lives in tinychannels; forward to its feature. -whatsapp-web = ["tinychannels/whatsapp-web"] +# It is a refinement INSIDE the `channels` gate — enabling it pulls in the whole +# channels domain (the provider re-exports live under `channels/providers/`). +whatsapp-web = ["channels", "tinychannels/whatsapp-web"] # Exposes the destructive `openhuman.test_reset` RPC. Off by default; the E2E # build (app/scripts/e2e-build.sh) flips it on. Shipped binaries never have # this feature so the wipe RPC isn't even registered, let alone reachable. e2e-test-support = [] +# Builds the `rss-bench` benchmark binary (#5046). Default-OFF, so it is never +# part of the shipped desktop/library build and the feature-forwarding gate +# (which only inspects the `default` list) never requires forwarding it. +rss-bench = [] +# Adds dhat heap profiling on top of `rss-bench` for the `library-profile` +# binary. Default-OFF, dev-only: installs dhat's global allocator, which +# perturbs RSS/timing numbers, so it is a separate opt-in feature rather than +# folded into `rss-bench`. Never forwarded to the shipped build. +rss-bench-dhat = ["rss-bench", "dep:dhat"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } @@ -508,12 +706,6 @@ tinyjuice = { path = "vendor/tinyjuice" } tinychannels = { path = "vendor/tinychannels" } tinyplace = { path = "vendor/tinyplace/sdk/rust" } -# TinyCortex temporarily pins the embedding API port while tinyagents #58 is -# pending. Resolve that git source to the same vendored crate so host adapters -# and TinyCortex share one `EmbeddingModel` trait identity. -[patch."https://github.com/senamakel/tinyagents"] -tinyagents = { path = "vendor/tinyagents" } - # Emit just enough DWARF in release builds for Sentry to symbolicate Rust # panics + render surrounding source lines. `line-tables-only` keeps the # binary small (only file+line tables, no full type info) while still diff --git a/Dockerfile b/Dockerfile index d7884f3278..a34fae653b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,6 +117,8 @@ ENV OPENHUMAN_WORKSPACE=/home/openhuman/.openhuman # Bind to all interfaces so the container is reachable ENV OPENHUMAN_CORE_HOST=0.0.0.0 ENV OPENHUMAN_CORE_PORT=7788 +# Stable first-party signal for CLI launch policy; containers default headless. +ENV OPENHUMAN_DOCKER=1 ENV RUST_LOG=info # AgentBox marketplace mode — off by default for desktop builds. The # AgentBox console flips this on per deployment, along with GMI_MAAS_*. diff --git a/app/package.json b/app/package.json index 76bb999dbd..f7693d14ee 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.61.6", + "version": "0.63.1", "type": "module", "engines": { "node": ">=24.0.0" @@ -78,8 +78,6 @@ "@noble/secp256k1": "^3.0.0", "@radix-ui/react-dialog": "^1.1.15", "@reduxjs/toolkit": "^2.11.2", - "@remotion/player": "4.0.454", - "@remotion/zod-types": "4.0.454", "@rive-app/react-webgl2": "^4.28.6", "@scure/base": "^2.2.0", "@scure/bip32": "^2.0.1", @@ -116,7 +114,6 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "remotion": "4.0.454", "socket.io-client": "^4.8.3", "tauri-plugin-ptt-api": "workspace:*", "three": "^0.183.2", diff --git a/app/scripts/e2e-run-shards.sh b/app/scripts/e2e-run-shards.sh index f6f9b8d5cc..62fb5b63f6 100755 --- a/app/scripts/e2e-run-shards.sh +++ b/app/scripts/e2e-run-shards.sh @@ -18,7 +18,8 @@ # chat = chat, skills, journeys # integrations = providers, webhooks, notifications # connectors = connectors -# commerce = payments, settings +# payments = payments +# settings = settings # set -uo pipefail @@ -32,7 +33,8 @@ SHARDS=( "providers:providers,notifications" "webhooks:webhooks" "connectors:connectors" - "commerce:payments,settings" + "payments:payments" + "settings:settings" ) # Allow filtering: `bash e2e-run-shards.sh foundation chat` diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 695043073c..e1aa752e4d 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.61.6" +version = "0.63.1" dependencies = [ "anyhow", "async-trait", @@ -205,7 +205,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -912,9 +912,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1217,15 +1217,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -1262,12 +1253,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "cocoa" version = "0.22.0" @@ -1292,7 +1277,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -1307,7 +1292,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1805,15 +1790,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1999,19 +1975,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -2033,7 +1996,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -2099,7 +2061,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2517,7 +2479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2536,7 +2498,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2687,12 +2649,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3047,15 +3003,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "fxhash" version = "0.2.1" @@ -3563,7 +3510,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -3575,15 +3522,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -3768,7 +3706,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4169,7 +4107,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4598,7 +4536,7 @@ dependencies = [ "indexmap 2.14.0", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -4665,9 +4603,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", ] @@ -4748,16 +4686,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5063,7 +4991,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5452,15 +5380,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5603,14 +5522,13 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.6" +version = "0.63.1" dependencies = [ "aes-gcm", "aho-corasick", "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -5622,13 +5540,11 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", "curve25519-dalek", - "dialoguer", "directories 6.0.0", "dirs 5.0.1", "docx-rs", @@ -5646,7 +5562,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -5655,28 +5571,20 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", - "prost", "rand 0.10.1", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rusqlite", "rustls", "rustls-pki-types", @@ -5688,7 +5596,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -5696,7 +5603,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents", "tinychannels", "tinycortex", "tinyflows", @@ -5708,15 +5615,11 @@ dependencies = [ "tokio-tungstenite 0.24.0", "tokio-util", "toml 1.1.2+spec-1.1.0", - "tower", "tracing", "tracing-appender", "tracing-log", "tracing-subscriber", "uiautomation", - "unicode-normalization", - "unicode-segmentation", - "unicode-width", "url", "urlencoding", "uuid 1.23.1", @@ -5774,75 +5677,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -6022,7 +5856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -6034,7 +5868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -6433,50 +6267,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -6617,20 +6407,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" @@ -7146,7 +6922,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -7169,7 +6944,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -7303,7 +7078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.11.1", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -7358,7 +7133,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7417,7 +7192,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7576,7 +7351,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -8076,21 +7851,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -8189,7 +7949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9049,7 +8809,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9210,7 +8970,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.9.0" +version = "2.1.0" dependencies = [ "async-trait", "bytes", @@ -9227,23 +8987,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tinyagents" -version = "2.0.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "tinychannels" version = "0.1.0" @@ -9258,7 +9001,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -9304,7 +9047,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "tinyagents 2.0.0", + "tinyagents", "tokio", "toml 0.8.2", "tracing", @@ -9324,7 +9067,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents", "tracing", ] @@ -9365,7 +9108,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -9442,32 +9185,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -9936,7 +9653,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10364,15 +10081,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -10391,15 +10099,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -10728,19 +10427,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -10763,7 +10449,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11915,7 +11601,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index fa6aba19e3..8347178fe5 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.61.6" +version = "0.63.1" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" @@ -161,14 +161,24 @@ cef = { version = "=146.4.1", default-features = false } # `scripts/ci/check-feature-forwarding.mjs` fails CI when this list drifts from # the core's `[features] default` (#4919) — do not hand-maintain it from memory. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "channels", "media", "tokenjuice-treesitter", + "inference", "voice", "web3", + "documents", "flows", "meet", "skills", "mcp", + "crash-reporting", + # The desktop shell reaches the in-process core only over + # http://127.0.0.1:/rpc, so it REQUIRES the HTTP + Socket.IO transport + # (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs. + "http-server", + "desktop-automation", + "medulla-local", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/profiling/.gitignore b/app/src-tauri/profiling/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/app/src-tauri/profiling/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/app/src-tauri/profiling/Cargo.lock b/app/src-tauri/profiling/Cargo.lock new file mode 100644 index 0000000000..d2b289f01f --- /dev/null +++ b/app/src-tauri/profiling/Cargo.lock @@ -0,0 +1,292 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "openhuman-tauri-resource-profiler" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sysinfo", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/app/src-tauri/profiling/Cargo.toml b/app/src-tauri/profiling/Cargo.toml new file mode 100644 index 0000000000..c79fecf389 --- /dev/null +++ b/app/src-tauri/profiling/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "openhuman-tauri-resource-profiler" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sysinfo = { version = "0.33", default-features = false, features = ["system"] } + +# Keep this offline dev tool independent from both the root core crate and the +# Tauri package so profiling never changes the shipped dependency graph. +[workspace] diff --git a/app/src-tauri/profiling/README.md b/app/src-tauri/profiling/README.md new file mode 100644 index 0000000000..61b78889e5 --- /dev/null +++ b/app/src-tauri/profiling/README.md @@ -0,0 +1,54 @@ +# Tauri resource profiler + +Offline developer tool for measuring a locally running OpenHuman desktop app. +It is a standalone Cargo crate and is not linked into, registered with, or +shipped in the Tauri app. + +## Capture + +Start the current checkout: + + pnpm dev:app + +Find the main host PID, excluding OpenHuman Helper processes: + + pgrep -fl '/OpenHuman$' + +Then capture a representative workload: + + pnpm profile:tauri --pid --duration 15 + +Useful options: + + --interval-ms 250 + --out target/profile/my-scenario + --no-stacks + +The default output directory is target/profile/tauri-resources-. +Each run writes: + +- resources.md: summary table for the Rust host, CEF process roles, and total + desktop process tree. +- resources.json: raw time series plus mean and peak values. +- cpu-stacks.txt: macOS sample report for the host process. This is enabled by + default on macOS and can be disabled with --no-stacks. + +## Attribution boundary + +The Rust core runs inside the Tauri host process. Operating-system CPU and RAM +metrics therefore cannot split the shell from openhuman_core, or assign heap +pages to individual Rust modules. The profiler reports that combined process +honestly as Tauri host + embedded Rust core. + +CEF renderer, GPU, utility, and other helper processes are separate and are +reported independently. On macOS, the tool also parses the recursive stack +counts from cpu-stacks.txt and groups OpenHuman symbols by Rust domain, such as +openhuman_core::openhuman::agent or openhuman::core_process. + +CPU percentages are percentages of one logical CPU and may exceed 100 when a +component uses multiple cores. RAM is resident memory reported by sysinfo. + +For the smaller embedded-core-only Linux RSS/PSS benchmark, use the existing +root rss-bench harness: + + cargo build --release --features rss-bench --bin rss-bench diff --git a/app/src-tauri/profiling/src/main.rs b/app/src-tauri/profiling/src/main.rs new file mode 100644 index 0000000000..782c7b70e9 --- /dev/null +++ b/app/src-tauri/profiling/src/main.rs @@ -0,0 +1,813 @@ +//! Offline CPU/RAM profiler for a locally running OpenHuman Tauri process. +//! +//! This binary is gated by the default-OFF dev-resource-profiler feature and +//! is never linked into the shipped app. It samples the Tauri host, which also +//! embeds openhuman_core, and its CEF descendants. On macOS it also captures an +//! Apple sample report for Rust module-level CPU attribution. +//! +//! Run from the repository root: +//! pnpm profile:tauri --pid PID --duration 15 + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use sysinfo::{Pid, ProcessesToUpdate, System, MINIMUM_CPU_UPDATE_INTERVAL}; + +const DEFAULT_DURATION_SECS: u64 = 15; +const DEFAULT_INTERVAL_MS: u64 = 250; + +#[derive(Debug)] +struct Args { + pid: u32, + duration: Duration, + interval: Duration, + out_dir: PathBuf, + capture_stacks: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +enum Component { + TauriHostAndEmbeddedCore, + CefRenderer, + CefGpu, + CefUtility, + CefOther, + OtherChild, +} + +impl Component { + fn label(self) -> &'static str { + match self { + Self::TauriHostAndEmbeddedCore => "Tauri host + embedded Rust core", + Self::CefRenderer => "CEF renderer", + Self::CefGpu => "CEF GPU", + Self::CefUtility => "CEF utility", + Self::CefOther => "CEF other", + Self::OtherChild => "Other child process", + } + } +} + +#[derive(Debug, Clone)] +struct ProcessSample { + pid: u32, + parent_pid: Option, + name: String, + command: String, + memory_bytes: u64, + cpu_percent: f32, +} + +#[derive(Debug, Clone, Serialize)] +struct ComponentSample { + component: Component, + process_count: usize, + memory_bytes: u64, + /// Percentage of one logical CPU. Above 100 means multiple logical CPUs. + cpu_percent: f32, +} + +#[derive(Debug, Clone, Serialize)] +struct TimeSample { + elapsed_ms: u64, + components: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +struct ComponentSummary { + component: Option, + sample_count: usize, + peak_process_count: usize, + mean_memory_bytes: u64, + peak_memory_bytes: u64, + mean_cpu_percent: f32, + peak_cpu_percent: f32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RustModuleCpu { + module: String, + recursive_samples: u64, +} + +#[derive(Debug, Serialize)] +struct ProfileReport { + schema_version: u32, + host_pid: u32, + duration_ms: u64, + interval_ms: u64, + logical_cpu_count: usize, + sampled_at_unix_ms: u128, + attribution_note: &'static str, + rust_binary: ComponentSummary, + desktop_total: ComponentSummary, + components: Vec, + samples: Vec, + rust_cpu_modules: Vec, + cpu_stack_report: Option, + cpu_stack_error: Option, +} + +struct ProfileCapture { + host_pid: u32, + duration: Duration, + interval: Duration, + logical_cpu_count: usize, + samples: Vec, + rust_cpu_modules: Vec, + cpu_stack_report: Option, + cpu_stack_error: Option, +} + +fn main() { + let arguments = env::args().skip(1).collect::>(); + if arguments + .iter() + .any(|argument| argument == "-h" || argument == "--help") + { + println!("{}", usage()); + return; + } + if let Err(err) = run(arguments) { + eprintln!("tauri-resource-profiler: {err}"); + std::process::exit(1); + } +} + +fn run(arguments: Vec) -> Result<(), String> { + let args = parse_args(arguments)?; + fs::create_dir_all(&args.out_dir) + .map_err(|err| format!("create output directory {}: {err}", args.out_dir.display()))?; + + let stack_path = args.out_dir.join("cpu-stacks.txt"); + let (mut stack_child, initial_stack_error) = if args.capture_stacks { + start_stack_capture(args.pid, args.duration, &stack_path) + } else { + (None, None) + }; + + let (logical_cpu_count, samples) = capture_samples(&args)?; + let final_stack_error = finish_stack_capture(stack_child.as_mut(), initial_stack_error); + let stack_report = stack_path.exists().then(|| { + stack_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + let rust_cpu_modules = fs::read_to_string(&stack_path) + .map(|contents| parse_rust_module_cpu(&contents)) + .unwrap_or_default(); + + let report = build_report(ProfileCapture { + host_pid: args.pid, + duration: args.duration, + interval: args.interval, + logical_cpu_count, + samples, + rust_cpu_modules, + cpu_stack_report: stack_report, + cpu_stack_error: final_stack_error, + })?; + let json_path = args.out_dir.join("resources.json"); + let markdown_path = args.out_dir.join("resources.md"); + fs::write( + &json_path, + serde_json::to_string_pretty(&report).map_err(|err| format!("serialize report: {err}"))?, + ) + .map_err(|err| format!("write {}: {err}", json_path.display()))?; + let markdown = render_markdown(&report); + fs::write(&markdown_path, &markdown) + .map_err(|err| format!("write {}: {err}", markdown_path.display()))?; + + println!("{markdown}"); + println!("Raw report: {}", json_path.display()); + if stack_path.exists() { + println!("CPU stacks: {}", stack_path.display()); + } + Ok(()) +} + +fn parse_args(arguments: impl IntoIterator) -> Result { + let mut pid = None; + let mut duration_secs = DEFAULT_DURATION_SECS; + let mut interval_ms = DEFAULT_INTERVAL_MS; + let mut out_dir = None; + let mut capture_stacks = cfg!(target_os = "macos"); + let mut arguments = arguments.into_iter(); + + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--pid" => pid = Some(parse_value::("--pid", arguments.next())?), + "--duration" => { + duration_secs = parse_value::("--duration", arguments.next())?; + } + "--interval-ms" => { + interval_ms = parse_value::("--interval-ms", arguments.next())?; + } + "--out" => { + out_dir = Some(PathBuf::from( + arguments + .next() + .ok_or_else(|| "--out requires a path".to_string())?, + )); + } + "--no-stacks" => capture_stacks = false, + "--stacks" => capture_stacks = true, + "--" => {} + "-h" | "--help" => unreachable!("main handles help before parsing"), + unknown => return Err(format!("unknown argument {unknown}\n\n{}", usage())), + } + } + + let pid = pid.ok_or_else(|| format!("--pid is required\n\n{}", usage()))?; + if duration_secs == 0 { + return Err("--duration must be greater than zero".into()); + } + if interval_ms == 0 { + return Err("--interval-ms must be greater than zero".into()); + } + let interval = Duration::from_millis(interval_ms).max(MINIMUM_CPU_UPDATE_INTERVAL); + + Ok(Args { + pid, + duration: Duration::from_secs(duration_secs), + interval, + out_dir: out_dir.unwrap_or_else(default_out_dir), + capture_stacks, + }) +} + +fn parse_value(flag: &str, value: Option) -> Result +where + T: std::str::FromStr, +{ + let raw = value.ok_or_else(|| format!("{flag} requires a value"))?; + raw.parse() + .map_err(|_| format!("invalid {flag} value {raw}")) +} + +fn usage() -> &'static str { + "Usage: pnpm profile:tauri --pid PID [--duration SECONDS] [--interval-ms MS] [--out PATH] [--stacks|--no-stacks]\n\nAttach to the main OpenHuman Tauri PID, not a CEF helper PID. On macOS, CPU stack sampling is enabled by default." +} + +fn default_out_dir() -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + PathBuf::from("target") + .join("profile") + .join(format!("tauri-resources-{timestamp}")) +} + +fn capture_samples(args: &Args) -> Result<(usize, Vec), String> { + let root_pid = Pid::from_u32(args.pid); + let mut system = System::new_all(); + if system.process(root_pid).is_none() { + return Err(format!("pid {} is not running", args.pid)); + } + + let started = Instant::now(); + let mut samples = Vec::new(); + while started.elapsed() < args.duration { + std::thread::sleep(args.interval); + system.refresh_processes(ProcessesToUpdate::All, true); + if system.process(root_pid).is_none() { + return Err(format!("pid {} exited during profiling", args.pid)); + } + let processes = collect_processes(&system); + samples.push(TimeSample { + elapsed_ms: started.elapsed().as_millis() as u64, + components: group_process_tree(args.pid, &processes)?, + }); + } + Ok((system.cpus().len(), samples)) +} + +fn collect_processes(system: &System) -> Vec { + system + .processes() + .iter() + .map(|(pid, process)| ProcessSample { + pid: pid.as_u32(), + parent_pid: process.parent().map(Pid::as_u32), + name: process.name().to_string_lossy().into_owned(), + command: process + .cmd() + .iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" "), + memory_bytes: process.memory(), + cpu_percent: process.cpu_usage(), + }) + .collect() +} + +fn group_process_tree( + root_pid: u32, + samples: &[ProcessSample], +) -> Result, String> { + let by_pid = samples + .iter() + .map(|sample| (sample.pid, sample)) + .collect::>(); + if !by_pid.contains_key(&root_pid) { + return Err(format!( + "host pid {root_pid} disappeared from the process table" + )); + } + + let mut grouped = BTreeMap::::new(); + for sample in samples + .iter() + .filter(|sample| belongs_to_tree(sample.pid, root_pid, &by_pid)) + { + let component = classify_process(sample, root_pid); + let entry = grouped.entry(component).or_insert(ComponentSample { + component, + process_count: 0, + memory_bytes: 0, + cpu_percent: 0.0, + }); + entry.process_count += 1; + entry.memory_bytes = entry.memory_bytes.saturating_add(sample.memory_bytes); + entry.cpu_percent += sample.cpu_percent; + } + Ok(grouped.into_values().collect()) +} + +fn belongs_to_tree(pid: u32, root_pid: u32, by_pid: &HashMap) -> bool { + let mut current = Some(pid); + let mut seen = HashSet::new(); + while let Some(candidate) = current { + if candidate == root_pid { + return true; + } + if !seen.insert(candidate) { + return false; + } + current = by_pid.get(&candidate).and_then(|sample| sample.parent_pid); + } + false +} + +fn classify_process(sample: &ProcessSample, root_pid: u32) -> Component { + if sample.pid == root_pid { + return Component::TauriHostAndEmbeddedCore; + } + let identity = format!("{} {}", sample.name, sample.command).to_ascii_lowercase(); + if identity.contains("--type=renderer") || identity.contains("renderer") { + Component::CefRenderer + } else if identity.contains("--type=gpu-process") + || identity.contains("gpu process") + || identity.contains("gpu-process") + { + Component::CefGpu + } else if identity.contains("--type=utility") || identity.contains("utility") { + Component::CefUtility + } else if identity.contains("--type=zygote") + || identity.contains("--type=broker") + || identity.contains("crashpad") + || identity.contains("cef") + { + Component::CefOther + } else { + Component::OtherChild + } +} + +fn build_report(capture: ProfileCapture) -> Result { + if capture.samples.is_empty() { + return Err("profiling produced no samples".into()); + } + + let mut by_component = BTreeMap::>::new(); + let mut totals = Vec::new(); + for sample in &capture.samples { + for component in &sample.components { + by_component + .entry(component.component) + .or_default() + .push(component.clone()); + } + totals.push(ComponentSample { + component: Component::TauriHostAndEmbeddedCore, + process_count: sample + .components + .iter() + .map(|value| value.process_count) + .sum(), + memory_bytes: sample + .components + .iter() + .map(|value| value.memory_bytes) + .sum(), + cpu_percent: sample + .components + .iter() + .map(|value| value.cpu_percent) + .sum(), + }); + } + let components = by_component + .iter() + .map(|(component, values)| summarize(Some(*component), values)) + .collect::>(); + let rust_binary = components + .iter() + .find(|summary| summary.component == Some(Component::TauriHostAndEmbeddedCore)) + .cloned() + .ok_or_else(|| "host process was not present in samples".to_string())?; + + Ok(ProfileReport { + schema_version: 1, + host_pid: capture.host_pid, + duration_ms: capture.duration.as_millis() as u64, + interval_ms: capture.interval.as_millis() as u64, + logical_cpu_count: capture.logical_cpu_count, + sampled_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + attribution_note: "The Rust core is embedded in the Tauri host, so OS process metrics report them together. CEF roles are separate child processes. Use cpu-stacks.txt to attribute host CPU samples to Rust modules.", + rust_binary, + desktop_total: summarize(None, &totals), + components, + samples: capture.samples, + rust_cpu_modules: capture.rust_cpu_modules, + cpu_stack_report: capture.cpu_stack_report, + cpu_stack_error: capture.cpu_stack_error, + }) +} + +fn summarize(component: Option, values: &[ComponentSample]) -> ComponentSummary { + let count = values.len().max(1); + ComponentSummary { + component, + sample_count: values.len(), + peak_process_count: values + .iter() + .map(|value| value.process_count) + .max() + .unwrap_or(0), + mean_memory_bytes: values.iter().map(|value| value.memory_bytes).sum::() + / count as u64, + peak_memory_bytes: values + .iter() + .map(|value| value.memory_bytes) + .max() + .unwrap_or(0), + mean_cpu_percent: values.iter().map(|value| value.cpu_percent).sum::() / count as f32, + peak_cpu_percent: values + .iter() + .map(|value| value.cpu_percent) + .fold(0.0, f32::max), + } +} + +fn render_markdown(report: &ProfileReport) -> String { + use std::fmt::Write as _; + let mut output = String::new(); + let _ = writeln!(output, "### Tauri resource profile"); + let _ = writeln!(output); + let _ = writeln!( + output, + "PID {} - {:.1}s - {}ms interval - {} logical CPUs", + report.host_pid, + report.duration_ms as f64 / 1000.0, + report.interval_ms, + report.logical_cpu_count + ); + let _ = writeln!(output); + let _ = writeln!( + output, + "| component | processes (peak) | RAM mean | RAM peak | CPU mean | CPU peak |" + ); + let _ = writeln!( + output, + "| --------- | ---------------- | -------- | -------- | -------- | -------- |" + ); + for summary in &report.components { + let label = summary + .component + .map(Component::label) + .unwrap_or("Desktop total"); + let _ = writeln!( + output, + "| {label} | {} | {:.1} MiB | {:.1} MiB | {:.1}% | {:.1}% |", + summary.peak_process_count, + to_mib(summary.mean_memory_bytes), + to_mib(summary.peak_memory_bytes), + summary.mean_cpu_percent, + summary.peak_cpu_percent, + ); + } + let total = &report.desktop_total; + let _ = writeln!( + output, + "| **Desktop total** | **{}** | **{:.1} MiB** | **{:.1} MiB** | **{:.1}%** | **{:.1}%** |", + total.peak_process_count, + to_mib(total.mean_memory_bytes), + to_mib(total.peak_memory_bytes), + total.mean_cpu_percent, + total.peak_cpu_percent, + ); + let _ = writeln!(output); + let _ = writeln!(output, "> {}", report.attribution_note); + if !report.rust_cpu_modules.is_empty() { + let _ = writeln!(output); + let _ = writeln!(output, "| Rust module | recursive CPU samples |"); + let _ = writeln!(output, "| ----------- | --------------------- |"); + for module in report.rust_cpu_modules.iter().take(20) { + let _ = writeln!( + output, + "| {} | {} |", + module.module, module.recursive_samples + ); + } + } + if let Some(error) = &report.cpu_stack_error { + let _ = writeln!(output); + let _ = writeln!(output, "CPU stack capture unavailable: {error}"); + } + output +} + +fn to_mib(bytes: u64) -> f64 { + bytes as f64 / (1024.0 * 1024.0) +} + +fn parse_rust_module_cpu(contents: &str) -> Vec { + let recursive_section = contents + .split("Total number in stack (recursive counted multiple") + .nth(1) + .and_then(|tail| tail.split("Sort by top of stack").next()) + .unwrap_or(""); + let mut modules = BTreeMap::::new(); + for line in recursive_section.lines() { + let trimmed = line.trim(); + let Some((count, symbol_line)) = trimmed.split_once(char::is_whitespace) else { + continue; + }; + let Ok(count) = count.parse::() else { + continue; + }; + let symbol = symbol_line + .split(" (in ") + .next() + .unwrap_or(symbol_line) + .trim(); + let Some(module) = own_rust_module(symbol) else { + continue; + }; + *modules.entry(module).or_default() += count; + } + let mut modules = modules + .into_iter() + .map(|(module, recursive_samples)| RustModuleCpu { + module, + recursive_samples, + }) + .collect::>(); + modules.sort_by(|left, right| { + right + .recursive_samples + .cmp(&left.recursive_samples) + .then_with(|| left.module.cmp(&right.module)) + }); + modules +} + +fn own_rust_module(symbol: &str) -> Option { + const CORE_PREFIX: &str = "openhuman_core::openhuman::"; + const TAURI_PREFIX: &str = "openhuman::"; + if let Some(start) = symbol.find(CORE_PREFIX) { + let tail = &symbol[start + CORE_PREFIX.len()..]; + let domain = tail + .split("::") + .next()? + .trim_matches(|value| value == '<' || value == '>'); + if !domain.is_empty() { + return Some(format!("{CORE_PREFIX}{domain}")); + } + } + if let Some(start) = symbol.find(TAURI_PREFIX) { + let tail = &symbol[start + TAURI_PREFIX.len()..]; + let module = tail + .split("::") + .next()? + .trim_matches(|value| value == '<' || value == '>'); + if !module.is_empty() { + return Some(format!("{TAURI_PREFIX}{module}")); + } + } + None +} + +#[cfg(target_os = "macos")] +fn start_stack_capture( + pid: u32, + duration: Duration, + output: &Path, +) -> (Option, Option) { + match Command::new("/usr/bin/sample") + .arg(pid.to_string()) + .arg(duration.as_secs().max(1).to_string()) + .arg("10") + .arg("-mayDie") + .arg("-file") + .arg(output) + .spawn() + { + Ok(child) => (Some(child), None), + Err(err) => (None, Some(format!("start /usr/bin/sample: {err}"))), + } +} + +#[cfg(not(target_os = "macos"))] +fn start_stack_capture( + _pid: u32, + _duration: Duration, + _output: &Path, +) -> (Option, Option) { + ( + None, + Some("automatic stack capture is currently available on macOS only".into()), + ) +} + +fn finish_stack_capture(child: Option<&mut Child>, prior_error: Option) -> Option { + if prior_error.is_some() { + return prior_error; + } + let child = child?; + match child.wait() { + Ok(status) if status.success() => None, + Ok(status) => Some(format!("stack sampler exited with {status}")), + Err(err) => Some(format!("wait for stack sampler: {err}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn process( + pid: u32, + parent_pid: Option, + name: &str, + command: &str, + memory_bytes: u64, + cpu_percent: f32, + ) -> ProcessSample { + ProcessSample { + pid, + parent_pid, + name: name.into(), + command: command.into(), + memory_bytes, + cpu_percent, + } + } + + #[test] + fn classifies_cef_roles() { + assert_eq!( + classify_process( + &process(2, Some(1), "OpenHuman Helper", "--type=renderer", 1, 1.0), + 1 + ), + Component::CefRenderer + ); + assert_eq!( + classify_process( + &process(3, Some(1), "OpenHuman Helper", "--type=gpu-process", 1, 1.0), + 1 + ), + Component::CefGpu + ); + assert_eq!( + classify_process( + &process(4, Some(1), "OpenHuman Helper", "--type=utility", 1, 1.0), + 1 + ), + Component::CefUtility + ); + } + + #[test] + fn groups_only_host_process_tree() { + let processes = vec![ + process(10, Some(1), "OpenHuman", "OpenHuman", 100, 20.0), + process(11, Some(10), "Helper", "--type=renderer", 40, 30.0), + process(12, Some(11), "Helper", "--type=utility", 10, 5.0), + process(99, Some(1), "unrelated", "unrelated", 1_000, 100.0), + ]; + + let grouped = group_process_tree(10, &processes).unwrap(); + assert_eq!(grouped.len(), 3); + assert_eq!( + grouped.iter().map(|value| value.memory_bytes).sum::(), + 150 + ); + assert_eq!( + grouped.iter().map(|value| value.cpu_percent).sum::(), + 55.0 + ); + } + + #[test] + fn report_separates_rust_binary_from_desktop_total() { + let samples = vec![TimeSample { + elapsed_ms: 250, + components: vec![ + ComponentSample { + component: Component::TauriHostAndEmbeddedCore, + process_count: 1, + memory_bytes: 100, + cpu_percent: 20.0, + }, + ComponentSample { + component: Component::CefRenderer, + process_count: 2, + memory_bytes: 50, + cpu_percent: 30.0, + }, + ], + }]; + let report = build_report(ProfileCapture { + host_pid: 10, + duration: Duration::from_secs(1), + interval: Duration::from_millis(250), + logical_cpu_count: 8, + samples, + rust_cpu_modules: Vec::new(), + cpu_stack_report: None, + cpu_stack_error: None, + }) + .unwrap(); + + assert_eq!(report.rust_binary.mean_memory_bytes, 100); + assert_eq!(report.desktop_total.mean_memory_bytes, 150); + assert_eq!(report.desktop_total.peak_process_count, 3); + assert!(render_markdown(&report).contains("Tauri host + embedded Rust core")); + } + + #[test] + fn parser_requires_pid_and_clamps_cpu_interval() { + assert!(parse_args(Vec::::new()).is_err()); + let args = parse_args([ + "--pid".into(), + "123".into(), + "--duration".into(), + "2".into(), + "--interval-ms".into(), + "1".into(), + "--no-stacks".into(), + ]) + .unwrap(); + assert_eq!(args.pid, 123); + assert_eq!(args.duration, Duration::from_secs(2)); + assert_eq!(args.interval, MINIMUM_CPU_UPDATE_INTERVAL); + assert!(!args.capture_stacks); + } + + #[test] + fn parses_recursive_stack_counts_into_openhuman_modules() { + let sample = r#" +Total number in stack (recursive counted multiple, when >=5): + 81 openhuman_core::openhuman::agent::run (in OpenHuman) + 10 + 34 ::poll (in OpenHuman) + 2 + 17 openhuman_core::openhuman::memory::search (in OpenHuman) + 4 + 9 openhuman::core_process::ensure_running (in OpenHuman) + 1 + 200 tokio::runtime::park (in OpenHuman) + 3 + +Sort by top of stack, same collapsed (when >= 5): +"#; + assert_eq!( + parse_rust_module_cpu(sample), + vec![ + RustModuleCpu { + module: "openhuman_core::openhuman::agent".into(), + recursive_samples: 115, + }, + RustModuleCpu { + module: "openhuman_core::openhuman::memory".into(), + recursive_samples: 17, + }, + RustModuleCpu { + module: "openhuman::core_process".into(), + recursive_samples: 9, + }, + ] + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b597458325..7f4a0d3c56 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -17,6 +17,21 @@ const _: () = assert!( Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." ); +// The shell talks to the in-process core only over http://127.0.0.1:/rpc, +// so the core MUST embed the HTTP + Socket.IO transport (#5048). Same failure +// class as #4901: with `http-server` dropped the core never binds a listener +// and every RPC is unreachable — silent and runtime-only. The marker lives in +// the core's always-compiled facade (`core::http_server_status`) precisely so +// this assert can observe the core's feature state (a dependent's own +// `#[cfg(feature = ...)]` would test THIS crate's features, not the core's). +const _: () = assert!( + openhuman_core::core::http_server_status::HTTP_SERVER_COMPILED_IN, + "openhuman_core must be built with the `http-server` feature: the desktop app reaches \ + the core only over http://127.0.0.1:/rpc, and without it the core binds no \ + listener so every RPC is unreachable (#5048). \ + Add \"http-server\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. @@ -2387,6 +2402,7 @@ pub fn run() { let custom_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(openhuman_core::core::runtime::MAX_BLOCKING_THREADS) .build() .expect("build custom tokio runtime for tauri async surface"); let handle = custom_runtime.handle().clone(); diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 7a71527e3f..1baae667c4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.61.6", + "version": "0.63.1", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef index 5ec3d8836c..11ef51edbe 160000 --- a/app/src-tauri/vendor/tauri-cef +++ b/app/src-tauri/vendor/tauri-cef @@ -1 +1 @@ -Subproject commit 5ec3d8836cbae300e36b7a9c0d302a65de92c58d +Subproject commit 11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/app/src/App.tsx b/app/src/App.tsx index 645b948731..09969b5df9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -30,8 +30,6 @@ import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import PttHotkeyManager from './components/PttHotkeyManager'; -import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner'; -import { EmergencyStopButton } from './components/safety/EmergencyStopButton'; import SecurityBanner from './components/SecurityBanner'; import SettingsModal from './components/settings/modal/SettingsModal'; import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay'; @@ -59,7 +57,6 @@ import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; -import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, @@ -259,16 +256,6 @@ export function AppShellDesktop() { // the core is ready (once per boot). Extracted to a hook so it's testable. useNotchBootSync(isBootstrapping); - // Boot hydration: read the authoritative halt state from the core once on - // mount so the UI reflects any halt that was engaged before this window - // opened (e.g. another tab, CLI, or a crash-recovery scenario). Errors are - // swallowed inside hydrateEmergencyState so a degraded core never blanks the shell. - useEffect(() => { - void hydrateEmergencyState(dispatch); - // Intentionally runs once on mount only. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const scrollRef = useRef(null); const navType = useNavigationType(); @@ -304,9 +291,6 @@ export function AppShellDesktop() { const content = (
- {/* Automation halt banner — renders at the top of the content area when - emergency stop is engaged. Always visible during automation sessions. */} - {activeProviderAccount && !accountsOverlayOpen && ( @@ -348,17 +332,6 @@ export function AppShellDesktop() { exhaustion). Mounted outside the routes so entries survive route changes and background-job completion. */} - {/* Emergency Stop — persistent safety control pinned to the top-right, - clear of the chat composer (bottom) and the sidebar (left); the - macOS traffic lights sit top-left, so the top-right stays free. The - button hides itself while halted (the AutomationHaltedBanner's - Resume takes over). Only shown when the shell chrome is visible - (i.e. the user is authenticated and past onboarding). */} - {!chromeless && ( -
- -
- )} {/* Hidden Remotion-driven producer for the Meet camera. Mounts a 640×480 JPEG frame stream to the Rust frame bus while a meet call is active; idle no-op otherwise. See diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 492e4240b8..836b227fe0 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -1,4 +1,4 @@ -import { type Location, Navigate, Route, Routes } from 'react-router-dom'; +import { type Location, Navigate, Route, Routes, useLocation } from 'react-router-dom'; import AgentWorldShell from './agentworld/AgentWorldShell'; import AgentWorld from './agentworld/pages/AgentWorld'; @@ -18,7 +18,6 @@ import FlowsPage from './pages/FlowsPage'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; -import OrchestrationPage from './pages/OrchestrationPage'; import { PttOverlayPage } from './pages/PttOverlayPage'; import Rewards from './pages/Rewards'; import Skills from './pages/Skills'; @@ -36,6 +35,54 @@ interface AppRoutesProps { location?: Location | string; } +/** + * Redirects the retired `/orchestration` route to its new home under Brain + * (`/brain?tab=orchestration`), mapping the legacy `?tab=`/`?sub=` query onto + * Brain's `?ov=`/`?sub=` scheme so old deep links land on the same view: + * - `?tab=connections|discover|usage` → `?ov=network&sub=` + * - `?tab=agent|overview|tasks|network|medulla` → `?ov=` + * - `?session=` is preserved for the agent chat. + */ +const NETWORK_SUBS = ['connections', 'discover', 'usage']; +const ORCH_VIEWS = ['medulla', 'agent', 'overview', 'tasks', 'network']; + +export function OrchestrationRedirect() { + const { search } = useLocation(); + const legacy = new URLSearchParams(search); + const tab = legacy.get('tab'); + // Privacy-safe: only the allowlisted branch id and whether a session param was + // present are logged — never the session value or any raw query string. + console.debug( + '[routes] orchestration-redirect: entry tab=%s', + tab && ORCH_VIEWS.includes(tab) ? tab : tab && NETWORK_SUBS.includes(tab) ? tab : '' + ); + + const next = new URLSearchParams(); + next.set('tab', 'orchestration'); + let branch: string; + if (tab && NETWORK_SUBS.includes(tab)) { + next.set('ov', 'network'); + next.set('sub', tab); + branch = 'network-sub'; + } else { + if (tab && ORCH_VIEWS.includes(tab)) next.set('ov', tab); + const sub = legacy.get('sub'); + if (sub && NETWORK_SUBS.includes(sub)) next.set('sub', sub); + branch = tab && ORCH_VIEWS.includes(tab) ? 'view' : 'default'; + } + const session = legacy.get('session'); + if (session) next.set('session', session); + + console.debug( + '[routes] orchestration-redirect: exit branch=%s ov=%s hasSub=%s hasSession=%s', + branch, + next.get('ov') ?? '', + next.has('sub'), + next.has('session') + ); + return ; +} + const AppRoutes = ({ location }: AppRoutesProps = {}) => { // Mobile target (iOS or Android): pair → Human/Chat/Settings only. // Desktop routes are not rendered. @@ -137,21 +184,16 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { } /> - {/* Orchestration — TinyPlace multi-agent coordination surface, promoted - from a Brain sub-tab into a first-class sidebar destination (sits - right after Workflows). */} - - - - } - /> - {/* Back-compat: the old Brain deep link → the promoted top-level tab. */} + {/* Orchestration folded back under Brain (`/brain?tab=orchestration`). + The old first-class `/orchestration` route and the even older Brain + deep link both redirect there; `` maps the + legacy `?tab=`/`?sub=` query onto Brain's `?ov=`/`?sub=` scheme so + deep links (e.g. `/orchestration?tab=tasks`) keep landing on the same + view. */} + } /> } + element={} /> {/* Back-compat: /activity and /intelligence → settings notifications page. */} diff --git a/app/src/agentworld/components/TransferHandleModal.test.tsx b/app/src/agentworld/components/TransferHandleModal.test.tsx new file mode 100644 index 0000000000..be9f250d58 --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.test.tsx @@ -0,0 +1,95 @@ +/** + * Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a + * Tiny Place handle transfer. A transfer is destructive/irreversible, so these + * assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a + * recipient, and that the flow fails CLOSED (error keeps the dialog open and + * never reports success). + * + * All handles/recipients are generic placeholders, never real identities. + */ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from './TransferHandleModal'; + +vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } })); + +const transfer = vi.mocked(apiClient.registry.transfer); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function setup() { + const onClose = vi.fn(); + const onTransferred = vi.fn(); + renderWithProviders( + + ); + return { onClose, onTransferred }; +} + +describe('TransferHandleModal', () => { + test('shows the handle + irreversible warning and gates confirm on a recipient', () => { + setup(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByText('@alpha')).toBeInTheDocument(); + expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument(); + // Confirm is disabled until a recipient is entered. + expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled(); + }); + + test('keeps confirm disabled until the exact handle is re-typed', async () => { + const user = userEvent.setup(); + setup(); + const confirmBtn = screen.getByTestId('transfer-handle-confirm'); + // A recipient alone is not enough for a destructive action. + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + expect(confirmBtn).toBeDisabled(); + // A wrong handle keeps it disabled. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong'); + expect(confirmBtn).toBeDisabled(); + // The exact handle (case- and @-insensitive) enables it. + await user.clear(screen.getByTestId('transfer-handle-confirm-input')); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA'); + expect(confirmBtn).toBeEnabled(); + }); + + test('confirming transfers to the resolved recipient, then closes on success', async () => { + const user = userEvent.setup(); + transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never }); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo'); + // The irreversible action is gated behind re-typing the handle. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + // Leading @ is stripped before the RPC; handle passed through verbatim. + await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo')); + await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument(); + }); + + test('fails closed: on error it shows the message and does not report success', async () => { + const user = userEvent.setup(); + transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place')); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + await waitFor(() => + expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i) + ); + // Fail closed: no success callbacks, dialog stays open. + expect(onTransferred).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + }); +}); diff --git a/app/src/agentworld/components/TransferHandleModal.tsx b/app/src/agentworld/components/TransferHandleModal.tsx new file mode 100644 index 0000000000..98464ca2bb --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -0,0 +1,155 @@ +/** + * TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929). + * + * A handle transfer is DESTRUCTIVE and irreversible for the sender: on success + * the recipient becomes the handle's sole owner. So this modal states that + * plainly, requires an explicit recipient, requires the user to re-type the + * handle to confirm intent, and takes an explicit confirm click — and it fails + * **closed**: on any error it keeps the dialog open with the message and never + * reports success. The core handler resolves the recipient @handle and + * read-back-confirms the new owner before this promise resolves, so a resolved + * transfer means the reassignment actually landed. + */ +import debugFactory from 'debug'; +import { useCallback, useState } from 'react'; + +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; +import { useT } from '../../lib/i18n/I18nContext'; +import { apiClient } from '../AgentWorldShell'; + +// Namespaced already ('agentworld:identity'), so messages carry no prefix. +const debug = debugFactory('agentworld:identity'); + +export interface TransferHandleModalProps { + /** The handle being transferred away (without a leading @). */ + handle: string; + onClose: () => void; + /** Called after a confirmed, read-back-verified transfer. */ + onTransferred: () => void; +} + +/** Normalize a handle for comparison: strip leading @, trim, lowercase. */ +function normalizeHandle(value: string): string { + return value.trim().replace(/^@+/, '').toLowerCase(); +} + +export default function TransferHandleModal({ + handle, + onClose, + onTransferred, +}: TransferHandleModalProps) { + const { t } = useT(); + const [recipient, setRecipient] = useState(''); + const [confirmText, setConfirmText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleClean = handle.replace(/^@+/, ''); + // Guard the irreversible action: the user must re-type the exact handle. + const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean); + + const submit = useCallback(async () => { + const target = recipient.trim().replace(/^@+/, ''); + if (!target) { + setError(t('agentWorld.transferHandle.recipientRequired')); + return; + } + // Belt-and-suspenders: the button is disabled without a match, but never + // execute a destructive transfer unless the typed confirmation matches. + if (!confirmMatches) { + setError(t('agentWorld.transferHandle.confirmMismatch')); + return; + } + setSubmitting(true); + setError(null); + // Never log the handle or recipient — both identify a user. + debug('handle transfer requested'); + try { + // Send the normalized handle (not the raw prop) so the invariant is local + // and doesn't rest on every caller pre-cleaning the value (#4998 review). + await apiClient.registry.transfer(handleClean, target); + debug('handle transfer confirmed'); + onTransferred(); + onClose(); + } catch (err) { + // Fail closed: keep the dialog open, show why, report no success. + // Log only the status (no raw error — it can carry backend/SDK detail); + // the raw message still surfaces in the UI via setError. + debug('handle transfer failed'); + setError(String(err)); + setSubmitting(false); + } + }, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]); + + return ( + undefined : onClose}> +
+

@{handleClean}

+

+ {t('agentWorld.transferHandle.warning')} +

+ + { + setRecipient(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={t('agentWorld.transferHandle.recipientPlaceholder')} + aria-label={t('agentWorld.transferHandle.recipientPlaceholder')} + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> + + {/* Type-to-confirm guard for the irreversible action. */} +
+

+ {t('agentWorld.transferHandle.confirmLabel')} +

+ { + setConfirmText(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={`@${handleClean}`} + aria-label={t('agentWorld.transferHandle.confirmLabel')} + data-testid="transfer-handle-confirm-input" + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index ddcea8893f..5469436d8d 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({ apiClient: { directory: { reverse: vi.fn() }, follows: { stats: vi.fn() }, - registry: { export: vi.fn(), assignPrimary: vi.fn() }, + registry: { export: vi.fn(), assignPrimary: vi.fn(), transfer: vi.fn() }, graphql: { user: vi.fn() }, users: { get: vi.fn(), updateProfile: vi.fn() }, }, @@ -362,6 +362,74 @@ function makeProfile(overrides: Partial = {}): GqlProfile { }; } +// GH-4929: a non-primary owned handle offers an enabled Transfer action that +// opens the destructive-confirm modal. #4998 M3: a primary (active) handle now +// renders a DISABLED Transfer control with an explanation (make another handle +// active first) instead of silently omitting it. A handle with no explicit +// primary flag (unknown state) shows no Transfer control at all. +describe('handle transfer action', () => { + test('opens the transfer confirm modal for a non-primary owned handle', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'primaryhandle', cryptoId: SOLANA_ADDR, primary: true }, + { ...minimalIdentity, username: 'giftme', cryptoId: SOLANA_ADDR, primary: false }, + ], + }) + ); + render(); + + // Both handles render a Transfer control (#4998 M3): the non-primary one is + // enabled, the primary one disabled. Pick the enabled one — it opens the modal. + const transferButtons = await screen.findAllByRole('button', { name: 'Transfer' }); + const enabled = transferButtons.find(b => !(b as HTMLButtonElement).disabled); + expect(enabled, 'the non-primary handle must expose an enabled Transfer control').toBeDefined(); + fireEvent.click(enabled!); + + // The destructive-confirm modal opens with its explicit confirm control. + expect(await screen.findByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Transfer handle' })).toBeInTheDocument(); + }); + + test('shows a disabled Transfer control with an explanation for a primary handle (#4998 M3)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'onlyprimary', cryptoId: SOLANA_ADDR, primary: true }, + ], + }) + ); + render(); + + // Rendered, but disabled and explained — not an invisible dead end. + const transferButton = await screen.findByRole('button', { name: 'Transfer' }); + expect(transferButton).toBeDisabled(); + expect(transferButton).toHaveAttribute( + 'title', + 'A primary handle cannot be transferred. Make another handle active first.' + ); + + // Clicking the locked control must NOT open the destructive modal. + fireEvent.click(transferButton); + expect(screen.queryByTestId('transfer-handle-modal')).not.toBeInTheDocument(); + }); + + test('shows no Transfer control when the primary flag is absent (unknown state)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [{ ...minimalIdentity, username: 'unknownstate', cryptoId: SOLANA_ADDR }], + }) + ); + render(); + + expect(await screen.findByRole('button', { name: 'Make active' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Transfer' })).not.toBeInTheDocument(); + }); +}); + describe('graphql-enriched profile card', () => { test('renders rich profile from graphql.user when available', async () => { graphqlUser.mockResolvedValueOnce( diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index 29268e8c97..f86bcce224 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -22,6 +22,7 @@ import { import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from '../components/TransferHandleModal'; const log = debug('agentworld:profile'); @@ -348,6 +349,8 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? // Handle currently being promoted to primary (in-flight), and any error. const [switchingHandle, setSwitchingHandle] = useState(null); const [switchError, setSwitchError] = useState(null); + // The owned handle whose transfer modal is open (without @), or null. + const [transferHandle, setTransferHandle] = useState(null); // ── Extract display fields from either data source ───────────────────────── const isGraphql = data.source === 'graphql'; @@ -568,6 +571,29 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? : 'Make active'} )} + {/* Transfer is destructive/irreversible — the modal confirms + intent and fails closed. A primary (active) handle is + locked from sale/transfer server-side, so it renders + disabled with an explanation of the path out (make another + handle active first) rather than silently vanishing (#4998 + M3). Only an explicit non-primary handle is transferable. */} + {id.primary === false && ( + + )} + {id.primary === true && ( + + )} {id.status} @@ -581,6 +607,14 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
)} + {transferHandle && ( + setTransferHandle(null)} + onTransferred={() => onSwitched?.()} + /> + )} + {followStats && (
diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx index 9e214e6e67..454b1c4a3c 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx @@ -82,6 +82,27 @@ describe('HarnessInitOverlay', () => { expect(second.container).toBeEmptyDOMElement(); }); + it('coalesces overlapping status polls into one RPC (StrictMode double-mount)', async () => { + // Hold the fetch pending so both mounts' immediate polls overlap. + let resolveFetch: (snap: HarnessInitSnapshot) => void = () => {}; + fetchHarnessInitStatus.mockImplementation( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + // Two concurrent overlays stand in for the effect→cleanup→effect double-mount. + renderWithProviders(); + renderWithProviders(); + + // Both immediate polls should share a single in-flight request. + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1); + + resolveFetch(snapshot({ overall: 'done', startedAt: 'warm-run' })); + await waitFor(() => expect(screen.queryByText('Run in background')).not.toBeInTheDocument()); + }); + it('reopens for a genuinely new provisioning run after a prior dismissal', async () => { // Dismiss the first run. fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'run-1' })); diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx index 0b6b0836da..bd8b79faf4 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -25,6 +25,30 @@ const UNKEYED_RUN = 'pending'; let dismissedRunMirror: string | null = null; +// Coalesce overlapping status polls onto a single in-flight request. React +// StrictMode double-mounts this overlay in dev (effect → cleanup → effect), +// and each setup fires an immediate poll — without this that boots two +// `harness_init_status` RPCs at the same instant. Concurrent callers share the +// in-flight promise; it clears once settled, so the ongoing (sequential) poll +// loop is unaffected. Also guards any genuine remount during the boot window. +let inflightStatusFetch: Promise | null = null; + +function fetchHarnessInitStatusCoalesced(): Promise { + if (inflightStatusFetch) { + log('status poll: joining in-flight request (coalesced)'); + return inflightStatusFetch; + } + log('status poll: dispatching harness_init_status'); + const pending = fetchHarnessInitStatus().finally(() => { + if (inflightStatusFetch === pending) { + inflightStatusFetch = null; + log('status poll: in-flight request settled, cache cleared'); + } + }); + inflightStatusFetch = pending; + return pending; +} + function runKey(snapshot: HarnessInitSnapshot | null): string { return snapshot?.startedAt ?? UNKEYED_RUN; } @@ -85,7 +109,7 @@ export default function HarnessInitOverlay() { const poll = async () => { try { - const next = await fetchHarnessInitStatus(); + const next = await fetchHarnessInitStatusCoalesced(); if (cancelledRef.current || dismissedRef.current) { return; } diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index 419c4b5606..5d62a0d393 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -1,7 +1,9 @@ +import debugFactory from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../lib/i18n/I18nContext'; +import { useCoreState } from '../providers/CoreStateProvider'; import { formatBytes, formatEta, @@ -18,7 +20,35 @@ import { } from '../utils/tauriCommands'; import Button from './ui/Button'; -const POLL_INTERVAL = 2000; +const log = debugFactory('local-ai-download'); + +const ACTIVE_POLL_INTERVAL = 2000; + +const IN_FLIGHT_STATES = new Set(['loading', 'downloading', 'installing']); + +/** Whether a `LocalAiStatus.state` string denotes an active download/bootstrap. */ +const isInFlightState = (state: string | undefined): boolean => + state != null && IN_FLIGHT_STATES.has(state); + +/** + * Pure predicate deciding whether a download/bootstrap is currently in flight, + * mirroring the `isDownloading` derivation used for rendering. Drives the fast + * poll's continuation: keep polling `inference_downloads_progress` + + * `inference_status` only while a download is genuinely in flight. + */ +const isDownloadInFlight = ( + status: LocalAiStatus | null, + downloads: LocalAiDownloadsProgress | null +): boolean => { + const downloadState = downloads?.state; + const currentState = isInFlightState(downloadState) + ? downloadState + : (status?.state ?? downloadState ?? 'idle'); + return ( + isInFlightState(currentState) || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1) + ); +}; /** * Persistent snackbar that shows local AI download progress. @@ -31,7 +61,7 @@ const LocalAIDownloadSnackbar = () => { const [downloads, setDownloads] = useState(null); const [dismissed, setDismissed] = useState(false); const [collapsed, setCollapsed] = useState(false); - const timerRef = useRef>(undefined); + const timerRef = useRef>(undefined); // Track previous isDownloading in state so we can reset the dismiss flag on a // not-downloading → downloading transition during render (render-phase update, // the officially recommended React pattern for adjusting state on derived-value changes). @@ -46,11 +76,26 @@ const LocalAIDownloadSnackbar = () => { } })(); - // Poll download status + // Detect an active download from the folded local-AI state in the app-state + // snapshot (polled by CoreStateProvider) instead of a dedicated idle poll of + // the inference RPCs. When idle this component issues ZERO inference calls; + // the snapshot's `runtime.localAi.state` is what flips us into the fast poll. + const { snapshot: coreSnapshot } = useCoreState(); + const coreDownloadActive = isInFlightState(coreSnapshot.runtime.localAi?.state ?? undefined); + + // While a download is in flight, poll the inference RPCs fast for smooth, + // granular progress/speed/ETA (the 2–5s app-state cadence is too coarse and + // carries no downloads-progress detail). The poll starts when core state + // reports activity and keeps going as long as the download itself is in + // flight, then stops — so there is no steady-state inference polling. useEffect(() => { - if (!tauriAvailable) return; + if (!tauriAvailable || !coreDownloadActive) return; + + let cancelled = false; + log('fast poll: starting (core reports download active)'); const poll = async () => { + let settled = false; try { const [statusRes, downloadsRes] = await Promise.all([ openhumanLocalAiStatus(), @@ -58,26 +103,54 @@ const LocalAIDownloadSnackbar = () => { ]); if (statusRes.result) setStatus(statusRes.result); if (downloadsRes.result) setDownloads(downloadsRes.result); - } catch { - // Silently ignore — core may not be ready + // The download reached a terminal state — stop the fast poll early + // rather than waiting for core state to catch up (it lags up to ~5s). + // A successful response that carries no `result` (a soft failure, not a + // thrown error) must be treated as transient, not as "download + // complete" — otherwise one empty blip would permanently freeze the + // fast poll for the rest of the download (same failure mode the catch + // below guards against for thrown errors). + if (statusRes.result || downloadsRes.result) { + settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null); + } + } catch (err) { + // Transient RPC failure (core may be busy). Do NOT treat this as + // settled: keep polling while core state still reports the download + // active, otherwise one blip would permanently stop progress updates + // for the rest of the download (the effect won't re-run until + // `coreDownloadActive` flips). + log('fast poll: transient error, will retry: %O', err); + } + if (!cancelled && !settled) { + timerRef.current = setTimeout(poll, ACTIVE_POLL_INTERVAL); + } else if (settled) { + log('fast poll: download settled, stopping'); } }; void poll(); - timerRef.current = setInterval(poll, POLL_INTERVAL); - return () => clearInterval(timerRef.current); - }, [tauriAvailable]); + return () => { + cancelled = true; + if (timerRef.current) clearTimeout(timerRef.current); + log('fast poll: stopped (unmount or core reports inactive)'); + }; + }, [tauriAvailable, coreDownloadActive]); const downloadState = downloads?.state; const currentState = downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing' ? downloadState : (status?.state ?? downloadState ?? 'idle'); + // Gate on `coreDownloadActive` so a download that the core no longer reports + // active can't leave the snackbar stuck: when the folded state flips inactive, + // polling stops and any still-"downloading" local status/downloads must not + // keep the overlay visible. const isDownloading = - currentState === 'loading' || - currentState === 'downloading' || - currentState === 'installing' || - (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1); + coreDownloadActive && + (currentState === 'loading' || + currentState === 'downloading' || + currentState === 'installing' || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1)); // Render-phase update: when a new download cycle starts (not-downloading → downloading), // reset the dismiss/collapsed flags so the snackbar reappears automatically. diff --git a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx index 8caf2bde80..1904cf565a 100644 --- a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx +++ b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCoreStateSnapshot, setCoreStateSnapshot } from '../../lib/coreState/store'; import { renderWithProviders } from '../../test/test-utils'; import LocalAIDownloadSnackbar from '../LocalAIDownloadSnackbar'; @@ -11,6 +12,29 @@ vi.mock('../../utils/tauriCommands', () => ({ openhumanLocalAiDownloadsProgress: vi.fn().mockResolvedValue({ result: null }), })); +/** + * Seed the folded `runtime.localAi.state` in core state. The snackbar reads this + * (instead of an idle inference poll) to decide whether to run its fast poll, so + * tests that expect it to poll must mark a download active here first. + */ +function seedLocalAiState(state: string | null): void { + const current = getCoreStateSnapshot(); + setCoreStateSnapshot({ + ...current, + snapshot: { + ...current.snapshot, + runtime: { + ...current.snapshot.runtime, + localAi: state === null ? null : ({ state } as never), + }, + }, + }); +} + +afterEach(() => { + seedLocalAiState(null); +}); + describe('LocalAIDownloadSnackbar', () => { it('does not render when not in Tauri environment', () => { renderWithProviders(); @@ -19,30 +43,26 @@ describe('LocalAIDownloadSnackbar', () => { expect(screen.queryByLabelText('Dismiss download notification')).not.toBeInTheDocument(); }); - it('does not render when no download is active', async () => { + it('does not poll or render when core state reports no active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'ready' } as never, - logs: [], - }); - vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockResolvedValue({ - result: { state: 'idle', progress: null } as never, - logs: [], - }); + vi.mocked(tauriCommands.openhumanLocalAiStatus).mockClear(); + vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockClear(); + seedLocalAiState('ready'); renderWithProviders(); - // Wait for poll cycle await vi.waitFor(() => { expect(screen.queryByText('Downloading')).not.toBeInTheDocument(); }); + // Idle → zero inference polls (the fold's whole point). + expect(tauriCommands.openhumanLocalAiStatus).not.toHaveBeenCalled(); + expect(tauriCommands.openhumanLocalAiDownloadsProgress).not.toHaveBeenCalled(); - // Reset mock vi.mocked(tauriCommands.isTauri).mockReturnValue(false); }); - it('renders immediately when status reports bootstrap activity before downloads progress catches up', async () => { + it('polls and renders when core state reports an active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ @@ -59,6 +79,7 @@ describe('LocalAIDownloadSnackbar', () => { result: { state: 'idle', progress: null } as never, logs: [], }); + seedLocalAiState('loading'); renderWithProviders(); diff --git a/app/src/components/channels/mcp/InstallDialog.test.tsx b/app/src/components/channels/mcp/InstallDialog.test.tsx index 1998ad107b..30c3eb9563 100644 --- a/app/src/components/channels/mcp/InstallDialog.test.tsx +++ b/app/src/components/channels/mcp/InstallDialog.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpRegistryUserError } from '../../../services/api/mcpRegistryErrors'; import InstallDialog from './InstallDialog'; const mockRegistryGet = vi.fn(); @@ -98,6 +99,22 @@ describe('InstallDialog', () => { expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument(); }); + it('shows friendly guidance instead of raw registry 404 JSON when detail load fails', async () => { + mockRegistryGet.mockRejectedValue( + new Error( + 'MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + render( {}} onCancel={() => {}} />); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Browse catalog' })).toBeInTheDocument(); + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); + }); + it('renders env key inputs after clicking configure', async () => { mockRegistryGet.mockResolvedValue(DETAIL); render( @@ -232,7 +249,34 @@ describe('InstallDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Install' })); }); - await waitFor(() => screen.getByText('Server error')); + await waitFor(() => screen.getByText('Install failed')); + }); + + it('shows friendly guidance instead of raw registry JSON when install re-fetch fails', async () => { + mockRegistryGet.mockResolvedValue(DETAIL); + mockInstall.mockRejectedValue( + new McpRegistryUserError( + 'not_found', + 'Failed to fetch registry detail: MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + + render( + {}} onCancel={() => {}} /> + ); + + await goToConfigureStep(); + fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'key' } }); + fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Install' })); + }); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); }); it('calls onCancel when Cancel is clicked on detail step', async () => { diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index 8be4f74744..9bfd1559b9 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { InstalledServer, SmitheryServerDetail } from './types'; @@ -74,7 +75,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta }) .catch(err => { if (latestQualifiedNameRef.current !== requestedName) return; - const msg = err instanceof Error ? err.message : t('mcp.install.failedDetail'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedDetail'); log('detail error: %s', msg); setDetailError(msg); }) @@ -144,7 +145,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta } onSuccess(server); } catch (err) { - const msg = err instanceof Error ? err.message : t('mcp.install.failedInstall'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedInstall'); log('install error: %s', msg); setInstallError(msg); } finally { @@ -182,7 +183,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta size="sm" onClick={onCancel} className="text-content-muted hover:underline"> - {t('mcp.install.back')} + {t('mcp.installed.browseCatalog')}
); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx index b1ec18a65a..26b349ce87 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx @@ -143,8 +143,10 @@ describe('McpCatalogBrowser', () => { expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument(); }); - it('shows error state when search fails', async () => { - mockRegistrySearch.mockRejectedValue(new Error('Network error')); + it('shows friendly guidance when search fails', async () => { + mockRegistrySearch.mockRejectedValue( + new Error('MCP official registry returned HTTP 500: {"detail":"upstream down"}') + ); render( {}} />); await act(async () => { @@ -152,6 +154,8 @@ describe('McpCatalogBrowser', () => { }); vi.useRealTimers(); - await waitFor(() => screen.getByText('Network error')); + await waitFor(() => screen.getByText(/The MCP registry is unavailable right now/)); + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.queryByText(/"detail":"upstream down"/)).not.toBeInTheDocument(); }); }); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.tsx index 9b669ff872..a8722919b6 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import McpServerCard from './McpServerCard'; import type { SmitheryServer } from './types'; @@ -57,7 +58,7 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => { log('loaded %d servers (append=%s)', incoming.length, append); } catch (err) { if (seq !== requestSeqRef.current) return; - const msg = err instanceof Error ? err.message : t('mcp.catalog.loadFailed'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed'); log('catalog fetch error: %s', msg); setError(msg); } finally { diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 55dac4aa61..4d59e7595d 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -520,7 +520,9 @@ describe('McpServersTab', () => { mockInstalledList.mockResolvedValue([]); mockStatus.mockResolvedValue([]); // First catalog fetch fails → error state; retry succeeds → rows render. - mockRegistrySearch.mockRejectedValueOnce(new Error('registry down')); + mockRegistrySearch.mockRejectedValueOnce( + new Error('MCP official registry returned HTTP 500: {"detail":"registry down"}') + ); mockRegistrySearch.mockResolvedValue({ servers: [{ qualified_name: 'a/srv', display_name: 'Recovered Srv', is_deployed: false }], page: 1, @@ -532,7 +534,9 @@ describe('McpServersTab', () => { // Error surfaces instead of a silent empty state. const errorBox = await screen.findByTestId('mcp-catalog-error'); - expect(errorBox).toHaveTextContent('Failed to load catalog'); + expect(errorBox).toHaveTextContent('The MCP registry is unavailable right now'); + expect(errorBox).toHaveTextContent('browse available MCP servers'); + expect(errorBox).not.toHaveTextContent('"detail":"registry down"'); expect(screen.queryByTestId('mcp-catalog-empty')).not.toBeInTheDocument(); // Retry re-fetches and renders the recovered catalog. diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index ca4e36596b..428560dc9a 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -17,6 +17,7 @@ import InstallDialog from './InstallDialog'; import InstalledServerDetail from './InstalledServerDetail'; import McpConnectionHealthToolbar from './McpConnectionHealthToolbar'; import McpInventoryPanel from './McpInventoryPanel'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types'; @@ -272,7 +273,7 @@ const McpServersTab = () => { const [catalogTotalPages, setCatalogTotalPages] = useState(1); // Set when a registry fetch fails so the Registry view shows an error state // (with retry) instead of silently falling back to an empty/stale catalog. - const [catalogError, setCatalogError] = useState(false); + const [catalogError, setCatalogError] = useState(null); const pollTimerRef = useRef | null>(null); const debounceRef = useRef | null>(null); @@ -317,18 +318,18 @@ const McpServersTab = () => { ); setCatalogPage(result.page); setCatalogTotalPages(result.total_pages); - setCatalogError(false); + setCatalogError(null); } catch (err) { if (seq !== requestSeqRef.current) return; log('catalog fetch error: %o', err); // A fresh (non-append) fetch that fails leaves no usable rows — surface // the error. A failed "load more" keeps the rows already shown. - if (!append) setCatalogError(true); + if (!append) setCatalogError(mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed')); } finally { if (seq === requestSeqRef.current) setCatalogLoading(false); } }, - [] + [t] ); useEffect(() => { @@ -739,7 +740,7 @@ const McpServersTab = () => {
-

{t('mcp.catalog.loadFailed')}

+

{catalogError}

+
+ ) : ( + <> +

+ {proposal.name || t('chat.flowProposal.title')} +

+

+ {t('chat.flowProposal.subtitle')} +

+ +

+ + {t('chat.flowProposal.triggerLabel')}: + {' '} + {proposal.summary.trigger} +

- {errorMsg &&

⚠ {errorMsg}

} - -
- - - -
+
+

+ {t('chat.flowProposal.stepsLabel')} +

+ {proposal.summary.steps.length > 0 ? ( +
    + {proposal.summary.steps.map((step, i) => { + const kindI18nKey = stepKindI18nKey(step.kind); + const kindLabel = kindI18nKey + ? t(kindI18nKey) + : humanizeUnknownStepKind(step.kind); + return ( +
  1. + + {kindLabel} + + {step.name} +
  2. + ); + })} +
+ ) : ( +

+ {t('chat.flowProposal.noSteps')} +

+ )} +
+ + {proposal.requireApproval && ( +

+ {t('chat.flowProposal.requireApprovalHint')} +

+ )} + + {errorMsg &&

⚠ {errorMsg}

} + +
+ + + +
+ + )}
diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index d762ff6806..74c05d63f1 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -294,7 +294,7 @@ describe('FlowRunsDrawer', () => { // Trigger the live-refresh poll fallback — issues the second, hanging // listFlowRuns('flow-a') call. await act(async () => { - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); }); expect(flowACalls).toBe(2); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index 368c73b779..17b95a63a5 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -28,7 +28,9 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunFinished } from '../../hooks/useFlowRunFinished'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { resolveDisplayStatus, useRunsPendingApprovalSet, @@ -83,6 +85,13 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr // it's stale once the drawer flips to a new flowId and bail instead of // clobbering the new flow's already-loaded runs. const currentFlowIdRef = useRef(flowId); + // Per-request generation counter, shared by the initial load effect and + // `refetch` below: a request started before a run-started event (or before + // a newer refetch) can resolve AFTER it and, without this guard, clobber a + // fresh "Running" row with stale data — even for the SAME flowId, where the + // `currentFlowIdRef` check alone can't tell requests apart. Only the + // most-recently-issued request for the current flow may apply its result. + const requestGenRef = useRef(0); useEffect(() => { currentFlowIdRef.current = flowId; @@ -99,16 +108,17 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr } let cancelled = false; + const requestGen = ++requestGenRef.current; setLoading(true); log('loading runs: flowId=%s', flowId); listFlowRuns(flowId) .then(result => { - if (cancelled) return; + if (cancelled || requestGen !== requestGenRef.current) return; setRuns(result); log('loaded runs: flowId=%s count=%d', flowId, result.length); }) .catch(err => { - if (cancelled) return; + if (cancelled || requestGen !== requestGenRef.current) return; const msg = err instanceof Error ? err.message : String(err); log('load failed: flowId=%s err=%s', flowId, msg); setError(msg); @@ -125,27 +135,43 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr // Background refresh for the live-update hook below — deliberately doesn't // touch `loading`/`error` so a poll tick or progress event never flashes // the loading state or clobbers a real load error with a transient one. - // Guards against a stale response: if the drawer flips from flow A to flow - // B while an A refetch is still in flight, the late A response must not - // overwrite B's already-loaded runs (mirrors the `cancelled` guard on the - // main load effect above). + // Guards against a stale response two ways: if the drawer flips from flow A + // to flow B while an A refetch is still in flight, the late A response must + // not overwrite B's already-loaded runs (`currentFlowIdRef`); and if two + // requests for the SAME flow race (e.g. the initial load and an + // event-driven refetch, or two refetches back to back), only the response + // to the most-recently-issued one may apply (`requestGenRef`). const refetch = useCallback(() => { if (!flowId) return; const requestFlowId = flowId; + const requestGen = ++requestGenRef.current; listFlowRuns(requestFlowId) .then(result => { - if (currentFlowIdRef.current !== requestFlowId) return; + if (currentFlowIdRef.current !== requestFlowId || requestGen !== requestGenRef.current) { + return; + } setRuns(result); log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); }) .catch(err => { - if (currentFlowIdRef.current !== requestFlowId) return; + if (currentFlowIdRef.current !== requestFlowId || requestGen !== requestGenRef.current) { + return; + } const msg = err instanceof Error ? err.message : String(err); log('refetch failed: flowId=%s err=%s', requestFlowId, msg); }); }, [flowId]); useFlowRunsLiveRefresh(runs, refetch); + // Unconditional (unlike useFlowRunsLiveRefresh, which is gated on an + // already-active run) — fills the empty-list gap ("No runs yet") that hook + // can't reach, so the very first run shows up as "Running" instantly + // instead of waiting for a manual refresh (issue B35). + useFlowRunStarted(() => void refetch(), flowId); + // Terminal companion to the above (issue B35 follow-up) — flips a run to + // Completed/Failed the instant it settles instead of waiting on + // `useFlowRunsLiveRefresh`'s debounced/backstop refetch to notice. + useFlowRunFinished(() => void refetch(), flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); useEscapeKey( diff --git a/app/src/components/flows/FlowRunsSidebar.test.tsx b/app/src/components/flows/FlowRunsSidebar.test.tsx index 667627949a..3cd6a451f0 100644 --- a/app/src/components/flows/FlowRunsSidebar.test.tsx +++ b/app/src/components/flows/FlowRunsSidebar.test.tsx @@ -24,6 +24,18 @@ vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); const fetchPendingApprovals = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); +// Stub the run-started hook (issue B35) so tests can trigger its `onStart` +// callback directly, without standing up a real socket subscription — its own +// match/filter/teardown behavior is covered by useFlowRunStarted.test.ts. +const flowRunStartedCalls = vi.hoisted( + () => [] as Array<{ onStart: () => void; flowId?: string | null }> +); +vi.mock('../../hooks/useFlowRunStarted', () => ({ + useFlowRunStarted: (onStart: () => void, flowId?: string | null) => { + flowRunStartedCalls.push({ onStart, flowId }); + }, +})); + // Capture the props handed to the drawer so "Fix with agent" can be invoked // directly without standing up the drawer's own run-polling machinery // (mirrors `FlowApprovalCard.test.tsx`'s stub pattern). @@ -100,6 +112,7 @@ describe('FlowRunsSidebar', () => { beforeEach(() => { vi.clearAllMocks(); inspectorDrawerProps.current = null; + flowRunStartedCalls.length = 0; fetchPendingApprovals.mockResolvedValue([]); }); @@ -212,4 +225,39 @@ describe('FlowRunsSidebar', () => { const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1'); await waitFor(() => expect(runRow).toHaveTextContent('Running')); }); + + it('registers useFlowRunStarted scoped to this flow and refetches when it fires (B35)', async () => { + listFlowRuns.mockResolvedValue([]); + renderSidebar('flow-1'); + + await screen.findByTestId('flow-runs-sidebar-empty'); + // The hook is called (with the same args) on every render — assert the + // most recent registration is scoped to this flow. + const latestCall = flowRunStartedCalls.at(-1); + expect(latestCall?.flowId).toBe('flow-1'); + expect(listFlowRuns).toHaveBeenCalledTimes(1); + + listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]); + act(() => { + latestCall?.onStart(); + }); + + await waitFor(() => expect(listFlowRuns.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(await screen.findByTestId('flow-runs-sidebar-run-run-1')).toHaveTextContent('Running'); + expect(screen.queryByTestId('flow-runs-sidebar-empty')).not.toBeInTheDocument(); + }); + + it('shows runs once a run starts even though the list began empty (B35)', async () => { + listFlowRuns.mockResolvedValue([]); + renderSidebar(); + + expect(await screen.findByTestId('flow-runs-sidebar-empty')).toBeInTheDocument(); + + listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]); + act(() => { + flowRunStartedCalls.at(-1)?.onStart(); + }); + + expect(await screen.findByTestId('flow-runs-sidebar-run-run-1')).toBeInTheDocument(); + }); }); diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index a999972526..3645f54fe5 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -11,10 +11,12 @@ * appears for a persisted flow (a draft has no runs yet). */ import createDebug from 'debug'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useFlowRunFinished } from '../../hooks/useFlowRunFinished'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { resolveDisplayStatus, useRunsPendingApprovalSet, @@ -84,12 +86,23 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { [navigate] ); + // Per-request generation counter: a `load()` started before a run-started + // event (see `useFlowRunStarted` below) can resolve AFTER the event-driven + // refetch and, without this guard, clobber the fresh "Running" row with + // stale data. Only the most recently-issued request may apply its result. + const requestGenRef = useRef(0); + const load = useCallback(async () => { log('loading runs for flow=%s', flowId); + const requestGen = ++requestGenRef.current; setLoading(true); setError(null); try { const result = await listFlowRuns(flowId); + if (requestGen !== requestGenRef.current) { + log('load: dropped stale response for flow=%s', flowId); + return; + } setRuns(result); log('loaded %d runs', result.length); } catch (err) { @@ -105,6 +118,21 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { }, [load]); useFlowRunsLiveRefresh(runs, load); + // Unconditional (unlike useFlowRunsLiveRefresh, which is gated on an + // already-active run) — fills the empty-list gap ("No runs yet") that + // hook can't reach, so the very first run shows up as "Running" instantly + // instead of waiting for a manual refresh (issue B35). + useFlowRunStarted(() => { + log('run-started: refetch flow=%s', flowId); + void load(); + }, flowId); + // Terminal companion to the above (issue B35 follow-up) — flips a run to + // Completed/Failed the instant it settles instead of waiting on + // `useFlowRunsLiveRefresh`'s debounced/backstop refetch to notice. + useFlowRunFinished(event => { + log('run-finished: refetch flow=%s run=%s status=%s', flowId, event.run_id, event.status); + void load(); + }, flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); return ( diff --git a/app/src/components/flows/ToolActivityChip.test.tsx b/app/src/components/flows/ToolActivityChip.test.tsx deleted file mode 100644 index d77a1be9df..0000000000 --- a/app/src/components/flows/ToolActivityChip.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import ToolActivityChip from './ToolActivityChip'; - -vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); - -describe('ToolActivityChip', () => { - it('renders the proposing label for propose_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the proposing label for revise_workflow too', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the dry-running label for dry_run_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.dryRunning' - ); - }); - - it('renders the saving label for save_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent('flows.copilot.tool.saving'); - }); - - it('renders a generic "using tools" label for an unrecognized tool name', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders nothing for an empty toolNames array', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders the shared label when every tool name maps to the same label', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the generic label when tool names map to different labels', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders the generic label when one tool is unrecognized, even if another is recognized', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); -}); diff --git a/app/src/components/flows/ToolActivityChip.tsx b/app/src/components/flows/ToolActivityChip.tsx deleted file mode 100644 index 0d39c1ea61..0000000000 --- a/app/src/components/flows/ToolActivityChip.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * ToolActivityChip — replaces raw tool-call JSON in the copilot chat with a - * compact, human-readable status pill (B25). Users should know the agent - * used a tool (it explains why the turn took longer), but they should never - * see the raw JSON arguments (e.g. the whole workflow graph payload). - */ -import { useT } from '../../lib/i18n/I18nContext'; - -interface Props { - /** Tool names extracted from the turn's tool-call envelope, in call order. */ - toolNames: string[]; -} - -/** Tools that map to a specific, more informative status label. */ -const KNOWN_TOOL_LABEL_KEYS: Record = { - propose_workflow: 'flows.copilot.tool.proposing', - revise_workflow: 'flows.copilot.tool.proposing', - dry_run_workflow: 'flows.copilot.tool.dryRunning', - save_workflow: 'flows.copilot.tool.saving', -}; - -export default function ToolActivityChip({ toolNames }: Props) { - const { t } = useT(); - if (toolNames.length === 0) return null; - - // Every tool must map to the SAME recognized label before we show a - // specific status (e.g. all of `propose_workflow`/`revise_workflow` map to - // "proposing..."); any unrecognized tool, or a mix of tools with different - // labels, falls back to a generic "Using tools..." pill rather than - // picking one tool's label arbitrarily or dumping tool names verbatim. - const labelKeys = toolNames.map(name => KNOWN_TOOL_LABEL_KEYS[name]); - const labelKey = labelKeys.every(key => key && key === labelKeys[0]) ? labelKeys[0] : undefined; - - return ( - - {t(labelKey ?? 'flows.copilot.tool.usingTools')} - - ); -} diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index f382f14692..926c5eaba6 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -2,28 +2,42 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types'; -import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice'; +import type { PendingApproval, WorkflowProposal } from '../../store/chatRuntimeSlice'; import WorkflowCopilotPanel from './WorkflowCopilotPanel'; vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); -interface MockMessage { - id: string; - content: string; - sender: 'user' | 'agent'; - extraMetadata?: { isInterim?: boolean }; -} +// The panel now delegates its entire transcript to the shared `ChatThreadView` +// (message bubbles, tool timeline, sub-agent drawer, streaming previews). That +// component reads the real Redux store; its rendering — including the B25 +// tool-call-envelope unwrap and interim-narration handling — is covered by +// `features/conversations/components/ChatThreadView.test.tsx`. Here we stub it +// so these tests stay focused on the copilot's OWN authoring behavior (the +// `flows_build` send path, seed auto-sends, and the proposal / capped cards) +// without needing a Redux Provider. +vi.mock('../../features/conversations/components/ChatThreadView', () => ({ + ChatThreadView: ({ emptyContent }: { emptyContent?: unknown }) => ( +
{emptyContent as never}
+ ), +})); + +// `ApprovalRequestCard` / `IntegrationConnectCard` (rendered for PR3: +// flows-copilot-live-run-approval) dispatch via `useAppDispatch` internally — +// stub the store hook rather than wrapping every render in a real Redux +// `Provider`, since these tests only assert which card renders, not the +// decide/connect flow those components own (covered by their own test files). +vi.mock('../../store/hooks', () => ({ useAppDispatch: () => vi.fn() })); +// Neither card calls this on mount (only on Approve/Deny/Connect click), but +// stub it defensively so a real network call can never sneak into a render +// test. +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); const hookState = vi.hoisted(() => ({ + threadId: 'builder-1' as string | null, sending: false, proposal: null as WorkflowProposal | null, + pendingApproval: null as PendingApproval | null, capped: false, - // Panel renders `displayMessages` (already interim-filtered upstream by - // `useWorkflowBuilderChat`) — kept separate from `messages` in these tests - // so a mismatch between the two proves the panel is reading the right field. - displayMessages: [] as MockMessage[], - toolTimeline: [] as ToolTimelineEntry[], - liveResponse: '', error: null as string | null, send: vi.fn(), clearProposal: vi.fn(), @@ -50,12 +64,11 @@ const baseGraph = graph(['a', 'b']); describe('WorkflowCopilotPanel', () => { beforeEach(() => { + hookState.threadId = 'builder-1'; hookState.sending = false; hookState.proposal = null; + hookState.pendingApproval = null; hookState.capped = false; - hookState.displayMessages = []; - hookState.toolTimeline = []; - hookState.liveResponse = ''; hookState.error = null; hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false }); hookState.clearProposal = vi.fn(); @@ -147,268 +160,13 @@ describe('WorkflowCopilotPanel', () => { expect(thirdArg.request.instruction).toBe('also add a filter step'); }); - it('renders the conversation transcript (user + agent turns)', () => { - hookState.displayMessages = [ - { id: 'm1', content: 'add a Slack step', sender: 'user' }, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - render( - - ); - expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step'); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - // With a transcript present, the empty-state hint is gone. - expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument(); - }); - - it('B25: unwraps a raw tool-call envelope message into clean text + a tool activity chip, never raw JSON', () => { - // Repro for B25: a turn that both talks and calls a tool can land in the - // thread transcript as the provider wire-format `{ content, tool_calls }` - // envelope. The panel must render only the human text — never the raw - // JSON — plus a compact status chip for the tool activity. - hookState.displayMessages = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: JSON.stringify({ - content: "Here's the workflow I propose.", - tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }], - }), - sender: 'agent', - }, - ]; - render( - - ); - const bubble = screen.getByTestId('workflow-copilot-agent'); - expect(bubble).toHaveTextContent("Here's the workflow I propose."); - // The raw envelope must never reach the DOM as text. - expect(bubble).not.toHaveTextContent('tool_calls'); - expect(bubble).not.toHaveTextContent('"nodes":[]'); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('does not render an isInterim agent message as a bubble, only the terminal one', () => { - // The panel only ever renders `displayMessages` — the same filtered set - // `useWorkflowBuilderChat` computes from the raw transcript (isInterim - // agent messages dropped since that narration already streams live via - // the tool timeline / live text). Mirror that filter here so this test - // documents (and would catch a regression in) the composition: an - // isInterim message must never reach the panel as a bubble, while the - // terminal (non-interim) answer still does. - const raw: MockMessage[] = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: 'Let me check your calendar first.', - sender: 'agent', - extraMetadata: { isInterim: true }, - }, - { id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim); - - render( - - ); - expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument(); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - }); - - it('renders the shared tool timeline + streaming reply during a builder turn', () => { - hookState.sending = true; - hookState.toolTimeline = [ - { id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry, - ]; - hookState.liveResponse = 'Drafting your workflow…'; - render( - - ); - // The shared ToolTimelineBlock renders (not the bespoke transcript), and the - // one-shot "thinking" placeholder is suppressed once activity is streaming. - expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - it('shows the live reply as a bubble before the first tool call streams', () => { - hookState.sending = true; - hookState.toolTimeline = []; - hookState.liveResponse = 'Thinking about your Slack digest…'; - render( - - ); - expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent( - 'Thinking about your Slack digest…' - ); - // No tool timeline yet, and the plain "thinking" line is replaced by the - // streamed text. - expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - // Regression coverage for the "copilot chat gets stuck" bug: the panel used - // to force-scroll to the bottom on every render of a streaming turn (an - // unconditional `scrollTo` effect keyed on messages/tool timeline/live - // text), which fought a user trying to scroll up to read. The panel now - // delegates to the shared `useStickToBottom` hook (same one the main chat - // surfaces use) — these tests exercise the REAL hook (not mocked) wired - // through the actual transcript container. - describe('transcript scroll pinning (#regression: chat gets stuck)', () => { - function scrollContainer() { - return screen.getByTestId('workflow-copilot-transcript'); - } - - // jsdom performs no real layout, so scroll metrics are inert unless - // defined explicitly — mirrors the approach in useStickToBottom.test.ts. - function mockScrollMetrics( - el: HTMLElement, - metrics: { scrollTop: number; scrollHeight: number; clientHeight: number } - ) { - Object.defineProperty(el, 'scrollHeight', { - configurable: true, - value: metrics.scrollHeight, - }); - Object.defineProperty(el, 'clientHeight', { - configurable: true, - value: metrics.clientHeight, - }); - Object.defineProperty(el, 'scrollTop', { - configurable: true, - writable: true, - value: metrics.scrollTop, - }); - } - - function renderPanel() { - return render( - - ); - } - - it('keeps the transcript container freely scrollable (overflow-y-auto)', () => { - renderPanel(); - expect(scrollContainer()).toHaveClass('overflow-y-auto'); - }); - - it('auto-scrolls to the bottom when a new message arrives while the user is pinned to the bottom', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // A new agent turn lands while the user never scrolled away. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 300, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(300); - }); - - it('does NOT force-scroll the user back down once they have scrolled up to read history', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // The user scrolls up to read earlier context, well past the stick - // threshold (400 - 0 - 50 = 350px from the bottom). - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); - fireEvent.scroll(container); - - // A new agent turn streams in regardless — this is exactly the bug: - // the old unconditional `scrollTo` effect would yank the reader back - // to the bottom here. The container must stay put. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Still drafting…', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 700, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(0); - }); - }); + // Transcript rendering (message bubbles, the shared tool timeline + sub-agent + // drawer, streaming previews, the B25 tool-call-envelope unwrap, interim + // narration, and stick-to-bottom scroll pinning) now lives in the shared + // `ChatThreadView` and is covered by + // `features/conversations/components/ChatThreadView.test.tsx`. The panel here + // stubs that component (see the mock above), so these tests assert only the + // copilot's own authoring surface (send path, seeds, proposal / capped cards). it('surfaces a new proposal to the host and shows the added/removed diff', () => { const onProposal = vi.fn(); @@ -499,6 +257,107 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.clearProposal).not.toHaveBeenCalled(); }); + // PR1 — "Save & enable": a second button next to "Accept & save" that asks + // the host to save AND arm the flow in one click, mirroring the main-chat + // `WorkflowProposalCard`'s create+arm parity. + describe('Save & enable (PR1)', () => { + it('calls onAccept with { enable: true } and clears the proposal once it resolves', async () => { + const onAccept = vi.fn().mockResolvedValue(undefined); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + expect(onAccept).toHaveBeenCalledWith(hookState.proposal, { enable: true }); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the enabling label and disables both accept buttons while the host save is in flight', async () => { + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toHaveTextContent( + 'flows.copilot.enabling' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toBeDisabled(); + // The plain "Accept & save" button must also be disabled while the + // enable-flavored save is in flight — the two must not race. + expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('leaves the proposal visible and shows an enable-error message when the host save/enable rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('enable failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, the proposal + // was never cleared (stays up for retry), and the dedicated enable-error + // message appears. + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).not.toBeDisabled() + ); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + expect(screen.getByTestId('workflow-copilot-enable-error')).toHaveTextContent( + 'flows.copilot.enableError' + ); + }); + + it('does not show the enable-error message for a plain Accept & save failure', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('save failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(screen.queryByTestId('workflow-copilot-enable-error')).not.toBeInTheDocument(); + }); + }); + it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => { // Regression for the CodeRabbit finding: Reject must not stay clickable // while `onAccept`'s save is still pending, otherwise the user's cancel @@ -1067,4 +926,94 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.send.mock.calls[0][0].request.mode).toBe('revise'); }); + + // PR3 (flows-copilot-live-run-approval): `flows_build` now runs the + // streaming turn under `AgentTurnOrigin::WebChat` + `APPROVAL_CHAT_CONTEXT`, + // so a parked `run_flow` / `resume_flow_run` call surfaces here via the same + // `pendingApproval` (sourced from `pendingApprovalByThread`) the main chat's + // `Conversations.tsx` reads — reusing the EXISTING `ApprovalRequestCard` / + // `IntegrationConnectCard`, no new component. + describe('parked approval surface (PR3: flows-copilot-live-run-approval)', () => { + function approvalOf(over: Partial = {}): PendingApproval { + return { + requestId: 'req-1', + toolName: 'run_flow', + message: 'Run the saved flow "Daily digest" to test it?', + ...over, + }; + } + + it('renders nothing when there is no pending approval', () => { + hookState.pendingApproval = null; + render( + + ); + expect(screen.queryByTestId('workflow-copilot-approval')).not.toBeInTheDocument(); + }); + + it('renders the shared ApprovalRequestCard for a parked run_flow/resume_flow_run/cancel_flow_run call', () => { + hookState.pendingApproval = approvalOf({ toolName: 'run_flow' }); + render( + + ); + expect(screen.getByTestId('workflow-copilot-approval')).toBeInTheDocument(); + // ApprovalRequestCard renders the parked call's message text verbatim. + expect(screen.getByText('Run the saved flow "Daily digest" to test it?')).toBeInTheDocument(); + }); + + it('renders IntegrationConnectCard (not ApprovalRequestCard) for a parked composio_connect call', () => { + hookState.pendingApproval = approvalOf({ + toolName: 'composio_connect', + toolkit: 'slack', + message: 'Connect slack to complete your task', + }); + render( + + ); + const surface = screen.getByTestId('workflow-copilot-approval'); + expect(surface).toBeInTheDocument(); + // IntegrationConnectCard's affordance is a Connect button, not + // Approve/Deny — assert the connect-specific copy is present and the + // approve/deny copy is not, distinguishing it from ApprovalRequestCard. + expect(screen.getByText('composio.connect.connect')).toBeInTheDocument(); + expect(screen.queryByText('chat.approval.approve')).not.toBeInTheDocument(); + }); + + it('does not render the approval surface when threadId is not yet established', () => { + // Guards the `pendingApproval && threadId` render condition: a parked + // approval with no resolved thread id (shouldn't happen in practice — + // an approval can only park on a thread `flows_build` already streamed + // into — but defends against a stale/mismatched hook state). + hookState.threadId = null; + hookState.pendingApproval = approvalOf(); + render( + + ); + expect(screen.queryByTestId('workflow-copilot-approval')).not.toBeInTheDocument(); + }); + }); }); diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 657e6bbb6a..214815fb53 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -8,13 +8,16 @@ * transcript, surfaces each proposal's node-level diff, and hands Accept/Reject * up to the host, which applies it to the local draft overlay. * - * Chat UI parity: the copilot reuses the SHARED chat surface end-to-end — the - * same {@link ChatComposer} the main chat windows use (mic/attachments off - * here), turns render as bubbles via the shared {@link BubbleMarkdown}, and the - * builder turn's live tool activity + streaming reply render through the shared - * {@link ToolTimelineBlock} (fed from the runtime's `toolTimelineByThread` / - * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads - * like a real chat rather than a one-shot form. + * Chat UI parity: the copilot renders its transcript through the SAME + * {@link ChatThreadView} the home composer chat uses — message bubbles, + * past-turn insights, the shared tool timeline + sub-agent drawer, and the + * streaming / interrupted / parallel previews — driven by this copilot's + * DEDICATED thread. `flows_build` streams the `workflow_builder` turn onto + * that thread via the global `ChatRuntimeProvider` (Phase B), exactly as a + * normal chat turn streams, so the copilot reads like the real chat rather + * than a bespoke transcript. This panel keeps only the authoring concerns: + * the {@link ChatComposer} footer (mic/attachments off), the seed auto-sends, + * and the proposal-preview + capped cards pinned above the composer. * * Invariant: the copilot only PROPOSES — the agent turn itself never * persists. Accept applies the proposal to the local draft AND immediately @@ -27,18 +30,16 @@ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { BubbleMarkdown } from '../../features/conversations/components/AgentMessageBubble'; -import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; -import { useStickToBottom } from '../../hooks/useStickToBottom'; +import { ChatThreadView } from '../../features/conversations/components/ChatThreadView'; import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat'; -import { unwrapToolCallEnvelope } from '../../lib/flows/copilotMessageSanitizer'; import { diffGraphs } from '../../lib/flows/graphDiff'; import type { WorkflowGraph } from '../../lib/flows/types'; import { useT } from '../../lib/i18n/I18nContext'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import ApprovalRequestCard from '../chat/ApprovalRequestCard'; import ChatComposer from '../chat/ChatComposer'; +import IntegrationConnectCard from '../chat/IntegrationConnectCard'; import Button from '../ui/Button'; -import ToolActivityChip from './ToolActivityChip'; const log = createDebug('app:flows:copilot-panel'); @@ -76,8 +77,14 @@ interface Props { * persists it — "accept" is now review + save in one step). May return a * promise the panel awaits to show a saving state; a rejected promise * leaves the proposal visible so the user can retry. + * + * `opts.enable` (PR1 — "Save & enable") requests an immediate follow-up + * arm after the save succeeds, mirroring the main-chat + * `WorkflowProposalCard`'s one-click create+arm. Optional and backward + * compatible — a plain "Accept & save" click omits `opts` entirely, so it + * neither enables nor force-disables an already-enabled existing flow. */ - onAccept: (proposal: WorkflowProposal) => void | Promise; + onAccept: (proposal: WorkflowProposal, opts?: { enable?: boolean }) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -154,19 +161,8 @@ export default function WorkflowCopilotPanel({ fullWidth = false, }: Props) { const { t } = useT(); - const { - threadId, - sending, - turnActive, - proposal, - capped, - displayMessages, - toolTimeline, - liveResponse, - error, - send, - clearProposal, - } = useWorkflowBuilderChat(seedThreadId); + const { threadId, sending, proposal, pendingApproval, capped, error, send, clearProposal } = + useWorkflowBuilderChat(seedThreadId); const [text, setText] = useState(''); // Report the (lazily-created) thread id up so the host persists it per flow — @@ -180,11 +176,25 @@ export default function WorkflowCopilotPanel({ const fileInputRef = useRef(null); const isComposingTextRef = useRef(false); + // Set only when a "Save & enable" attempt's `onAccept` rejects — surfaced + // as a dedicated inline message (`flows.copilot.enableError`) distinct from + // a plain "Accept & save" failure, which stays silent-but-retryable as + // before (the button re-enabling is signal enough there). Declared early + // (ahead of the proposal-surfacing effect below, which also clears it) so + // both that effect and the accept/reject handlers further down can + // reference it without a temporal-dead-zone ordering issue. + const [enableError, setEnableError] = useState(false); + // Surface each NEW proposal to the host exactly once (enter preview overlay). const lastSurfacedRef = useRef(null); useEffect(() => { if (proposal && proposal !== lastSurfacedRef.current) { lastSurfacedRef.current = proposal; + // A genuinely new proposal object replacing a prior one (e.g. a further + // revise turn) supersedes any stale "Save & enable" failure from the + // earlier proposal — clear it so the new card doesn't inherit an + // unrelated error message. + setEnableError(false); onProposal(proposal); } }, [proposal, onProposal]); @@ -313,36 +323,11 @@ export default function WorkflowCopilotPanel({ onPrefillSeedConsumed?.(); }, [prefillSeed, onPrefillSeedConsumed]); - // Keep the transcript pinned to the newest message / streamed activity — - // but ONLY while the user is already at (or near) the bottom. The previous - // implementation here was an unconditional `scrollTo(bottom)` effect keyed - // on every streaming dependency (messages, tool timeline, live text, …): - // it fired on every streamed token and force-scrolled regardless of where - // the user was reading, which is what made the transcript feel "stuck" — - // any attempt to scroll up got yanked back down by the very next token. - // `useStickToBottom` is the same pinning hook the main chat surfaces use: - // it only auto-scrolls while `stickingRef` is true (user at/near bottom), - // and permanently disengages the moment the user scrolls away, so reading - // history is never fought. `resetKey` is a stable constant here — this - // panel is fully unmounted/remounted on close/reopen (see the seed refs - // above), so there's no in-place "navigation" case to reset for. - const { containerRef: scrollRef } = useStickToBottom( - displayMessages, - threadId, - 'workflow-copilot' - ); - useEffect(() => { - log( - 'scroll: stick-to-bottom deps changed messages=%d thread=%s sending=%s hasProposal=%s timeline=%d liveTextLen=%d', - displayMessages.length, - threadId ?? 'null', - sending, - Boolean(proposal), - toolTimeline.length, - liveResponse.length - ); - }, [displayMessages, threadId, sending, proposal, toolTimeline, liveResponse]); - + // Transcript rendering + scroll pinning (stick-to-bottom) are owned by the + // shared `ChatThreadView` below — the copilot no longer hand-rolls the + // transcript. This component keeps only the authoring concerns: the + // structured `flows_build` send path, the seed auto-sends, and the + // proposal / capped cards surfaced in the footer. const submit = useCallback( async (raw?: string) => { const trimmed = (raw ?? text).trim(); @@ -413,49 +398,63 @@ export default function WorkflowCopilotPanel({ // Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`) // applies the proposal to the draft AND persists it. Track a local - // `acceptSaving` flag so the button can show a saving state and disable - // re-clicks while that's in flight. If the host's save throws, leave the - // proposal card visible (don't `clearProposal()`) so the user can retry — - // otherwise a failed autosave would silently vanish the only affordance to - // try again from the copilot itself (the header Save button is a fallback, - // but this keeps the copilot's own flow self-contained). - const [acceptSaving, setAcceptSaving] = useState(false); - const accept = useCallback(async () => { - // Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on - // the Accept button prevents a normal double-click, but `acceptSaving` - // only flips after the FIRST call's `setAcceptSaving(true)` commits — a - // second invocation racing ahead of that render (e.g. programmatic - // re-fire) must not start a second concurrent save. - if (!proposal || acceptSaving) return; - setAcceptSaving(true); - log('accept: saving proposal via host onAccept'); - try { - await onAccept(proposal); - log('accept: save succeeded, clearing proposal'); - clearProposal(); - lastSurfacedRef.current = null; - } catch (err) { - log('accept: save failed, leaving proposal visible for retry err=%o', err); - } finally { - setAcceptSaving(false); - } - }, [proposal, acceptSaving, onAccept, clearProposal]); + // `acceptState` union (rather than a plain boolean) so the two accept + // buttons ("Accept & save" / "Save & enable", PR1) can each show their own + // in-flight label while BOTH stay disabled — a save-in-flight click on the + // other button, or Reject, must not race the pending persist. If the + // host's save (or enable) throws, leave the proposal card visible (don't + // `clearProposal()`) so the user can retry — otherwise a failed autosave + // would silently vanish the only affordance to try again from the copilot + // itself (the header Save button is a fallback, but this keeps the + // copilot's own flow self-contained). + const [acceptState, setAcceptState] = useState<'idle' | 'saving' | 'enabling'>('idle'); + const acceptBusy = acceptState !== 'idle'; + const runAccept = useCallback( + async (opts?: { enable?: boolean }) => { + // Self-guard against re-entrance: the JSX `disabled={acceptBusy}` on + // both buttons prevents a normal double-click, but `acceptState` only + // flips after the FIRST call's `setAcceptState(...)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptBusy) return; + const enable = Boolean(opts?.enable); + setAcceptState(enable ? 'enabling' : 'saving'); + setEnableError(false); + log('accept: saving proposal via host onAccept enable=%s', enable); + try { + // Plain "Accept & save" calls `onAccept` with just the proposal (no + // second argument at all) — matching the pre-PR1 call signature + // exactly — so a host that doesn't care about `opts` (or a caller + // asserting on `onAccept`'s exact arguments) sees no behavioral + // change. Only "Save & enable" adds the `{ enable: true }` opts. + if (enable) { + await onAccept(proposal, opts); + } else { + await onAccept(proposal); + } + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save (or enable) failed, leaving proposal visible for retry err=%o', err); + if (enable) setEnableError(true); + } finally { + setAcceptState('idle'); + } + }, + [proposal, acceptBusy, onAccept, clearProposal, setEnableError] + ); + const accept = useCallback(() => runAccept(), [runAccept]); + const acceptAndEnable = useCallback(() => runAccept({ enable: true }), [runAccept]); const reject = useCallback(() => { onReject(); clearProposal(); lastSurfacedRef.current = null; - }, [onReject, clearProposal]); + setEnableError(false); + }, [onReject, clearProposal, setEnableError]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; - const hasTimeline = toolTimeline.length > 0; - // B25: the in-flight streaming text can also carry the raw tool-call - // envelope mid-turn — unwrap once and reuse the clean text everywhere below - // (the pre-tool streaming bubble and the shared `ToolTimelineBlock`). - const liveResponseText = unwrapToolCallEnvelope(liveResponse).text; - const hasLiveText = liveResponseText.trim().length > 0; - const isEmpty = - displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText; return (