From f715ca8ce99aa25e4dd35b40de727a7870542667 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 29 Jul 2026 13:45:47 +0800 Subject: [PATCH] chore(runtime): add profiling contracts and audit gates --- .../RpcPerformanceClosure.md | 125 ++++++++++ docs/contributing/macos-memory-profiling.md | 62 +++++ docs/contributing/rust-heap-profiling.md | 109 +++++++++ .../RpcPerformanceClosure.md | 41 ++++ scripts/dev/profile-macos-memory.sh | 148 ++++++++++++ scripts/dev/profile-rust-heap.sh | 44 ++++ src-tauri/.cargo/config.toml | 12 + src-tauri/Cargo.lock | 104 +++++++- src-tauri/Cargo.toml | 14 ++ .../crates/session-persistence/Cargo.toml | 4 + .../bindings/CacheStats.ts | 6 + .../crates/session-persistence/src/types.rs | 1 + src-tauri/crates/transport/Cargo.toml | 4 + .../crates/transport/bindings/AgentEvent.ts | 6 + .../crates/transport/bindings/TextChunk.ts | 6 + .../crates/transport/bindings/ToolEvent.ts | 7 + .../transport/bindings/ToolEventType.ts | 6 + .../crates/transport/src/adapters/mock.rs | 129 +--------- .../crates/transport/src/adapters/tauri.rs | 152 +----------- .../transport/src/tests/transport_tests.rs | 28 ++- src-tauri/crates/transport/src/traits.rs | 17 +- src-tauri/src/dhat_profiling.rs | 226 ++++++++++++++++++ src-tauri/src/lib.rs | 10 + src-tauri/src/main.rs | 5 + .../rpc/__tests__/outputValidation.test.ts | 107 +++++++++ src/api/tauri/rpc/invoke.ts | 95 +++++++- 26 files changed, 1185 insertions(+), 283 deletions(-) create mode 100644 docs/architecture-audit-2026-07-29/RpcPerformanceClosure.md create mode 100644 docs/contributing/macos-memory-profiling.md create mode 100644 docs/contributing/rust-heap-profiling.md create mode 100644 docs/org2-performance-guard-2026-07-29/RpcPerformanceClosure.md create mode 100755 scripts/dev/profile-macos-memory.sh create mode 100755 scripts/dev/profile-rust-heap.sh create mode 100644 src-tauri/crates/session-persistence/bindings/CacheStats.ts create mode 100644 src-tauri/crates/transport/bindings/AgentEvent.ts create mode 100644 src-tauri/crates/transport/bindings/TextChunk.ts create mode 100644 src-tauri/crates/transport/bindings/ToolEvent.ts create mode 100644 src-tauri/crates/transport/bindings/ToolEventType.ts create mode 100644 src-tauri/src/dhat_profiling.rs create mode 100644 src/api/tauri/rpc/__tests__/outputValidation.test.ts diff --git a/docs/architecture-audit-2026-07-29/RpcPerformanceClosure.md b/docs/architecture-audit-2026-07-29/RpcPerformanceClosure.md new file mode 100644 index 0000000000..f97a10442f --- /dev/null +++ b/docs/architecture-audit-2026-07-29/RpcPerformanceClosure.md @@ -0,0 +1,125 @@ +# Architecture Audit — Runtime Profiling and Contract Gates + +**Scope:** PR #568 rebuilt on the current `develop`, limited to opt-in memory +profiling, RPC output-validation policy, generated Rust-to-TypeScript bindings, +and transport adapter serialization. +**Date:** 2026-07-30 +**Auditor:** Codex cleanup session + +## Acceptance criteria + +- The PR contains one intended commit and no commits from the pre-force-push + `develop` history. +- Normal builds do not link DHAT, replace the allocator, start a profiling + worker, or validate RPC outputs in production. +- The opt-in DHAT lifecycle has one owner and finalizes at Tauri exit. +- RPC drift diagnostics have an explicit mode and a fixed memory bound. +- Mock and Tauri transport adapters serialize the same typed payload. +- Generated TypeScript bindings match the serialized Rust field names. +- No dependency removal, lockfile churn, missing quality-script entry, or + unrelated product/UI code remains. + +## Layer 1 — Compilation correctness + +- `pnpm typecheck`: passed. +- RPC output-validation Vitest: 6/6 passed. +- `cargo test -p transport --features ts-rs`: 9/9 passed. +- `cargo test -p session_persistence --features ts-rs`: 37/37 passed. +- `cargo check -p org2 --features dhat-heap`: passed. +- Changed-file rustfmt: passed. Whole-workspace rustfmt still reports unchanged + formatting drift outside this PR. +- The workspace still reports one unchanged `unused_mut` warning in + `orgtrack-core`; no changed module emitted a warning. + +## Layer 2 — Dead code and structural deduplication + +- The Tauri and mock adapters now delegate field naming to the same serde + definitions instead of maintaining parallel JSON maps. +- The transport emitter remains a pre-existing initialized service with no + production emit call sites outside its crate. This PR does not claim that the + transport events are a live product path; its tests enforce adapter/mock wire + parity for the existing boundary. +- The replay originally removed `ali-react-table` and `clsx`; that unrelated + dependency cleanup and its lockfile churn were removed from PR #568. +- A `check:component-boundaries` package entry whose script did not exist on + current `develop` was also removed. + +## Layer 3 — Naming consistency + +| Term | Meaning | Verdict | +| --- | --- | --- | +| `dhat-heap` | Explicit developer-only Rust heap-profiling feature | consistent | +| `RpcOutputValidationMode` | Process-wide `off` / `warn` / `throw` response policy | consistent | +| `output drift` | Rust response that fails the declared frontend output schema | consistent | +| `AgentEvent` | Tagged typed payload emitted on `agent://event` | consistent | + +## Layer 4 — Semantic overloading + +- Profiling activation is separate from normal runtime diagnostics. +- RPC validation mode is separate from command execution success. +- Transport event `type` identifies the agent-event variant; the Tauri channel + name identifies the event family. Neither is used as the other. + +## Layer 5 — Default branch analysis + +| Branch or fallback | Result | +| --- | --- | +| Normal Cargo feature set | DHAT dependency, allocator, worker, and exit hook are absent | +| Invalid or oversized start delay | Falls back to the documented 15-second delay | +| Browser global unavailable | RPC drift recording is a no-op | +| Production RPC policy | Output validation is `off` | +| Development/test RPC policy | Output validation is `warn`; explicit CI can select `throw` | +| Repeated profiler scheduling/finalization | State machine makes both idempotent | + +## Layer 6 — Cross-domain concept leakage + +- DHAT ownership stays in the desktop crate and is feature-gated. +- Session-persistence exports only its own `CacheStats` DTO. +- RPC policy stays at the typed invoke boundary. +- Transport payload types remain in the transport crate and do not import UI + or session-persistence concepts. + +## Layer 7 — New developer confusion test + +- Profiling scripts and docs name the required feature, delay, output path, and + shutdown requirement. +- Generated bindings make the Rust/TypeScript contract discoverable. +- Comments distinguish developer profiling from production behavior. + +## Layer 8 — Wire protocol and serialization + +| Boundary | Source of truth | Verification | +| --- | --- | --- | +| Agent lifecycle | `AgentEvent` serde tag/content and camel-case attributes | transport tests inspect serialized JSON | +| Text stream | `TextChunk` serde attributes | generated binding test and adapter payload test | +| Tool event | `ToolEvent` / `ToolEventType` serde attributes | generated binding test and adapter payload test | +| Cache stats | `CacheStats` serde attributes | generated binding test | +| RPC response | Procedure output Zod schema | 6-mode/failure-path Vitest cases | + +Opaque tool `params` and `result` intentionally export as `unknown`; numeric +cache counters export as `number` under the repository's bounded-value +contract. + +## Layer 9 — Init parity + +| Entry path | Normal build | `dhat-heap` build | +| --- | --- | --- | +| Desktop startup | Existing runtime only | Installs the gated allocator and schedules one profiler start | +| Tauri exit | Existing shutdown | Finalizes the profiler once before process exit | +| Tests/other crates | No profiling initialization | No implicit initialization | + +## Layer 10 — Resolver symmetry + +- The profiler delay and output path each resolve from one documented + environment variable with one fallback. +- RPC output policy has one module-level source of truth used by every + `typedInvoke` call; set/get/reset operate on that same state. +- No multi-field account, model, workspace, or identity resolver is changed. + +## Verdict + +The rebuilt PR is scoped to runtime profiling and contract enforcement. The +historical branch contamination, unrelated dependency removal, broken package +script, and stale PR #512 audit claims were removed. + +**Architecture verdict: pass.** diff --git a/docs/contributing/macos-memory-profiling.md b/docs/contributing/macos-memory-profiling.md new file mode 100644 index 0000000000..c22d6598e4 --- /dev/null +++ b/docs/contributing/macos-memory-profiling.md @@ -0,0 +1,62 @@ +# macOS memory profiling with Instruments + +Use macOS Instruments for interactive ORGII memory investigations. Unlike the +opt-in DHAT allocator, Instruments can attach to a normally running app without +capturing a Rust backtrace under a global lock for every allocation. + +For a quick, zero-profiler-overhead comparison, click the gauge button in the +ORGII navigation sidebar. Its panel separates the native backend, WebView +helpers, file cache, terminal buffers, and other runtime estimates. Record the +values before the workload, after repeating it, and again after returning to +idle. Use Instruments when that comparison points to persistent native-backend +growth that needs allocation call stacks. + +## Record native allocations + +1. Start ORGII normally and wait for the main window to become usable. +2. From the repository root, run: + + ```bash + ./scripts/dev/profile-macos-memory.sh + ``` + +3. Exercise one controlled workload during the two-minute recording. +4. Let the recording finish. ORGII remains open, and the script prints the + generated `.trace` path. +5. Open the trace using the printed command, then inspect persistent bytes, + allocation growth, and responsible call trees. + +The default `Allocations` recording is limited to two minutes and attaches only +to the native `org2` process. Override the limit when necessary: + +```bash +./scripts/dev/profile-macos-memory.sh --duration 5m +``` + +If multiple ORGII instances are running, select the intended backend explicitly: + +```bash +./scripts/dev/profile-macos-memory.sh --pid 12345 +``` + +Run the `Leaks` template for a bounded native leak scan: + +```bash +./scripts/dev/profile-macos-memory.sh --template Leaks --duration 2m +``` + +The first recording may cause macOS to request Developer Tools permission. If +attachment is denied, enable the terminal under **System Settings → Privacy & +Security → Developer Tools**, restart the terminal, and retry. + +## What the trace covers + +Attaching to `org2` covers native allocations in the Tauri/Rust backend and +native frameworks loaded into that process. It does not provide the JavaScript +object-retainer graph inside the WebKit WebContent helper. Use Web Inspector +heap snapshots for JavaScript/DOM leaks, and use the built-in App memory +snapshot or Activity Monitor when comparing total backend plus helper RSS. + +For comparable results, record the same duration and workload before and after +a change. Let the app return to idle before the recording ends so persistent +growth is easier to distinguish from temporary peaks. diff --git a/docs/contributing/rust-heap-profiling.md b/docs/contributing/rust-heap-profiling.md new file mode 100644 index 0000000000..d98c1bc777 --- /dev/null +++ b/docs/contributing/rust-heap-profiling.md @@ -0,0 +1,109 @@ +# Rust heap profiling with DHAT + +ORGII includes an opt-in DHAT build for diagnosing allocations retained by the +Rust desktop backend. It is a developer tool, not a production feature and not +a replacement for the App memory/RSS monitor. + +DHAT can impose extreme per-allocation overhead on the full ORGII GUI and may +make the Tauri window temporarily unresponsive. For normal interactive memory +investigations on macOS, prefer +[Instruments](./macos-memory-profiling.md). Reserve this DHAT path for short, +controlled Rust-backend scenarios where that slowdown is acceptable. + +## Run a profile + +From the repository root: + +```bash +./scripts/dev/profile-rust-heap.sh +``` + +The script first builds the production frontend, then builds the `org2` desktop +binary with the optimized, symbolized `dhat` Cargo profile and enables the +`dhat-heap` feature. This keeps the measured Tauri/WebView path close to the +release application while preserving Rust allocation symbols. + +After Tauri backend setup completes, DHAT waits 15 seconds before it starts. +Wait for the terminal's `[dhat] Rust heap profiling started` message, exercise +one controlled scenario, then quit ORGII normally. The final Tauri exit event +synchronously writes a timestamped JSON profile below +`${TMPDIR:-/tmp}/orgii-dhat-profiles/`, and the script prints the exact path. A +forced process kill cannot flush the profile. + +On macOS, quit the application with `Cmd+Q`. The red window button only hides +the main window and does not end the profiling process. + +Override the post-setup delay when a scenario needs more or less settling +time. Values from 0 through 3600 seconds are accepted: + +```bash +ORGII_DHAT_START_DELAY_SECS=30 ./scripts/dev/profile-rust-heap.sh +``` + +To choose the output file explicitly: + +```bash +ORGII_DHAT_OUTPUT=/absolute/path/session-switch.json \ + ./scripts/dev/profile-rust-heap.sh +``` + +For repeated profiling while `build/` is already current, skip the webpack +rebuild explicitly: + +```bash +ORGII_DHAT_SKIP_FRONTEND_BUILD=true ./scripts/dev/profile-rust-heap.sh +``` + +The skip mode refuses to run unless `build/index.html` exists. + +Open the JSON file in the +[DHAT viewer](https://nnethercote.github.io/dh_view/dh_view.html). Compare +`At t-gmax` for the peak and `At t-end` for allocations still live when the app +closed. Allocation call stacks are more useful than the headline byte count. + +## Recommended scenario shape + +1. Launch the app and wait for the terminal to confirm profiling has started. +2. Repeat one operation enough times to expose growth, such as opening and + leaving replay-heavy sessions. +3. Return to an idle screen and wait for configured eviction grace periods. +4. Quit the app normally with `Cmd+Q` so the final Tauri exit event writes the profile. The red window button only hides the app on macOS. +5. Repeat with the same workload after a proposed fix and compare call stacks, + peak bytes, and end-of-run live bytes. + +DHAT starts once, after backend setup plus the configured settling delay. +Startup allocations before that point are intentionally excluded so allocation +backtrace collection cannot stall the initial WebView render. Before profiling +starts, the feature-gated allocator delegates directly to the system allocator +without entering DHAT's tracking lock. Use repeated before/after scenarios +instead of treating the final total as a standalone pass/fail number. + +## Scope and limitations + +DHAT replaces the Rust process-wide global allocator only when the +`dhat-heap` feature is enabled. Default development and release builds retain +their normal allocator and do not link the optional `dhat` dependency. + +DHAT measures allocations made through the Rust allocator in the main ORGII +backend process. It does not attribute: + +- JavaScript objects, DOM nodes, Jotai atoms, or WebView caches; +- xterm/WebGL/GPU memory; +- terminal, CLI-agent, MCP, or other child processes; +- memory-mapped files, kernel/file caches, or the complete process RSS. + +Use WebKit/Chromium DevTools heap snapshots for the WebView object graph and +the built-in App memory snapshot for whole-process and owned-helper trends. + +## Manual Cargo invocation + +After `pnpm run build`, the equivalent backend command is: + +```bash +ORGII_DHAT_OUTPUT=/absolute/path/dhat-heap.json \ + cargo run --manifest-path src-tauri/Cargo.toml \ + --profile dhat --features dhat-heap --bin org2 +``` + +Do not add `dhat-heap` to default features or production build scripts. The +profiling allocator intentionally adds substantial runtime and memory overhead. diff --git a/docs/org2-performance-guard-2026-07-29/RpcPerformanceClosure.md b/docs/org2-performance-guard-2026-07-29/RpcPerformanceClosure.md new file mode 100644 index 0000000000..3486124895 --- /dev/null +++ b/docs/org2-performance-guard-2026-07-29/RpcPerformanceClosure.md @@ -0,0 +1,41 @@ +# Performance Guard — Runtime Profiling and Contract Gates + +**Scope:** PR #568 rebuilt on current `develop`. +**Date:** 2026-07-30 + +## Lifecycle matrix + +| Dimension | Audited behavior | +| --- | --- | +| App | Normal builds create no profiler resource. A `dhat-heap` build schedules one worker and finalizes one profiler guard at Tauri exit. | +| Document | Developer heap profiling intentionally continues across visible/hidden states so the requested measurement window is complete. No production timer is added. | +| Network | No network request, retry, subscription, or polling path is added. | +| Identity | Profiling and RPC drift diagnostics retain no account, endpoint, org, or session identity. | +| Scope | RPC drift records contain command, schema issues, and timestamp only. The buffer is process-local and capped at 200 records. | +| Session | No per-session registry or retained session payload is added. | +| Instance | Each desktop process owns its feature-gated profiler state and output path; no cross-process cache is introduced. | + +## Findings and evidence + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| --- | --- | --- | --- | --- | +| Background work | keep | `schedule_from_env` can spawn one named thread only when `dhat-heap` is compiled; the delay is one-shot and bounded to one hour. | State transitions reject repeat scheduling; Tauri `Exit` finalizes or cancels the pending state. | `dhat_profiling` unit tests plus feature-enabled application check. | +| Memory | keep | One optional DHAT guard and `window.__orgiiRpcOutputDrift`. | Guard is dropped at exit; drift array evicts oldest records above 200. | RPC tests cover the cap and all validation modes. | +| Scope/isolation | keep | No identity-bearing cache; output path comes from the current process environment. | Profiling output and diagnostics remain process-local. | Static lifecycle trace; no account/endpoint path changed. | +| Rendering/hot path | keep | Production RPC output-validation default is `off`. | Development/CI validation is explicit; normal rendering gets no schema-parse overhead from this change. | RPC mode tests and TypeScript typecheck. | +| Wire serialization | keep | Transport adapters call serde directly instead of constructing parallel JSON objects. | One typed serialization path avoids duplicate field-remapping work and drift. | Transport tests inspect agent, text, and tool payloads. | + +## Verification + +- RPC output-validation Vitest: 6/6 passed. +- `cargo test -p transport --features ts-rs`: 9/9 passed. +- `cargo test -p session_persistence --features ts-rs`: 37/37 passed. +- `pnpm typecheck`, changed-file ESLint, script `bash -n`, changed-file + rustfmt, and `cargo check -p org2 --features dhat-heap`: passed. +- Whole-workspace rustfmt reports unchanged formatting drift outside this PR. +- A real Tauri profiling run and visible/hidden/post-exit measurement were not + repeated during this branch-history cleanup. + +**Performance verdict: blocked — static ownership, bounds, tests, and compile +gates are covered, but a real feature-enabled Tauri profiling run was not +collected in this cleanup session.** diff --git a/scripts/dev/profile-macos-memory.sh b/scripts/dev/profile-macos-memory.sh new file mode 100755 index 0000000000..8a4bac9b42 --- /dev/null +++ b/scripts/dev/profile-macos-memory.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +set -euo pipefail + +readonly DEFAULT_DURATION="2m" +readonly DEFAULT_TEMPLATE="Allocations" +readonly DEFAULT_OUTPUT_DIR="${TMPDIR:-/tmp}/orgii-instruments" + +TARGET_PID="${ORGII_INSTRUMENTS_PID:-}" +DURATION="${ORGII_INSTRUMENTS_DURATION:-${DEFAULT_DURATION}}" +TEMPLATE="${ORGII_INSTRUMENTS_TEMPLATE:-${DEFAULT_TEMPLATE}}" +OUTPUT_PATH="${ORGII_INSTRUMENTS_OUTPUT:-}" + +show_help() { + cat <<'EOF' +Profile the native ORGII backend with macOS Instruments. + +Start ORGII normally first, then run: + ./scripts/dev/profile-macos-memory.sh + +Options: + --pid PID Attach to this org2 process instead of auto-detecting. + --duration TIME Recording limit, such as 30s, 2m, or 1h (default: 2m). + --template NAME Allocations or Leaks (default: Allocations). + --output PATH.trace Explicit trace output path. + --help, -h Show this help. + +Environment equivalents: + ORGII_INSTRUMENTS_PID + ORGII_INSTRUMENTS_DURATION + ORGII_INSTRUMENTS_TEMPLATE + ORGII_INSTRUMENTS_OUTPUT +EOF +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --pid) + [[ $# -ge 2 ]] || fail "--pid requires a value" + TARGET_PID="$2" + shift 2 + ;; + --duration) + [[ $# -ge 2 ]] || fail "--duration requires a value" + DURATION="$2" + shift 2 + ;; + --template) + [[ $# -ge 2 ]] || fail "--template requires a value" + TEMPLATE="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 ]] || fail "--output requires a value" + OUTPUT_PATH="$2" + shift 2 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + fail "unknown option: $1 (run with --help for usage)" + ;; + esac +done + +[[ "$(uname -s)" == "Darwin" ]] || fail "this profiler requires macOS" +command -v xcrun >/dev/null 2>&1 || fail "xcrun was not found; install Xcode command-line tools" +[[ "${DURATION}" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]] \ + || fail "duration must be a positive value with ms, s, m, or h suffix" + +case "${TEMPLATE}" in + Allocations) + TEMPLATE_SLUG="allocations" + ;; + Leaks) + TEMPLATE_SLUG="leaks" + ;; + *) fail "template must be Allocations or Leaks" ;; +esac + +if [[ -z "${TARGET_PID}" ]]; then + MATCHING_PIDS=() + while IFS= read -r matching_pid; do + MATCHING_PIDS+=("${matching_pid}") + done < <( + { + pgrep -x org2 2>/dev/null || true + pgrep -x ORG2 2>/dev/null || true + } | sort -nu + ) + + case "${#MATCHING_PIDS[@]}" in + 0) + fail "no running ORGII backend found; start ORGII normally, then rerun this script" + ;; + 1) + TARGET_PID="${MATCHING_PIDS[0]}" + ;; + *) + echo "Multiple ORGII backend processes are running:" >&2 + ps -p "$(IFS=,; echo "${MATCHING_PIDS[*]}")" -o pid=,etime=,command= >&2 || true + fail "choose one explicitly with --pid PID" + ;; + esac +fi + +[[ "${TARGET_PID}" =~ ^[1-9][0-9]*$ ]] || fail "PID must be a positive integer" +kill -0 "${TARGET_PID}" 2>/dev/null || fail "PID ${TARGET_PID} is not running or is not accessible" + +if [[ -z "${OUTPUT_PATH}" ]]; then + readonly OUTPUT_DIR="${ORGII_INSTRUMENTS_DIR:-${DEFAULT_OUTPUT_DIR}}" + mkdir -p "${OUTPUT_DIR}" + OUTPUT_PATH="${OUTPUT_DIR}/orgii-${TEMPLATE_SLUG}-$(date '+%Y%m%d-%H%M%S')-${TARGET_PID}.trace" +else + [[ "${OUTPUT_PATH}" == *.trace ]] || fail "output path must end in .trace" + mkdir -p "$(dirname -- "${OUTPUT_PATH}")" +fi + +[[ ! -e "${OUTPUT_PATH}" ]] || fail "output already exists: ${OUTPUT_PATH}" + +echo "Recording ORGII native memory with Instruments..." +echo " PID: ${TARGET_PID}" +echo " Template: ${TEMPLATE}" +echo " Duration: ${DURATION}" +echo " Output: ${OUTPUT_PATH}" +echo "Exercise one controlled workload in ORGII now. The app remains running when recording ends." + +if ! xcrun xctrace record \ + --template "${TEMPLATE}" \ + --attach "${TARGET_PID}" \ + --time-limit "${DURATION}" \ + --output "${OUTPUT_PATH}"; then + echo "Instruments could not attach to PID ${TARGET_PID}." >&2 + echo "Enable your terminal in System Settings → Privacy & Security → Developer Tools, restart the terminal, and retry." >&2 + exit 1 +fi + +[[ -e "${OUTPUT_PATH}" ]] || fail "Instruments finished without creating ${OUTPUT_PATH}" + +echo "Instruments trace saved: ${OUTPUT_PATH}" +echo "Open it with: open \"${OUTPUT_PATH}\"" diff --git a/scripts/dev/profile-rust-heap.sh b/scripts/dev/profile-rust-heap.sh new file mode 100755 index 0000000000..b29378db27 --- /dev/null +++ b/scripts/dev/profile-rust-heap.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +readonly PROFILE_DIR="${ORGII_DHAT_DIR:-${TMPDIR:-/tmp}/orgii-dhat-profiles}" +readonly PROFILE_STAMP="$(date '+%Y%m%d-%H%M%S')" +readonly PROFILE_FILE="${ORGII_DHAT_OUTPUT:-${PROFILE_DIR}/orgii-dhat-${PROFILE_STAMP}-$$.json}" + +mkdir -p "$(dirname -- "${PROFILE_FILE}")" + +if [[ "${ORGII_DHAT_SKIP_FRONTEND_BUILD:-false}" != "true" ]]; then + echo "Building the production frontend used by the optimized Tauri profile..." + ( + cd "${REPO_ROOT}" + pnpm run build + ) +elif [[ ! -f "${REPO_ROOT}/build/index.html" ]]; then + echo "ORGII_DHAT_SKIP_FRONTEND_BUILD=true requires an existing build/index.html" >&2 + exit 1 +fi + +echo "Building and running the optimized DHAT profile..." +echo "Wait for the '[dhat] Rust heap profiling started' message before testing." +echo "Quit ORGII with Cmd+Q to finalize the heap profile; closing the window only hides it." +echo "Profile output: ${PROFILE_FILE}" + +( + cd "${REPO_ROOT}" + ORGII_DHAT_OUTPUT="${PROFILE_FILE}" cargo run \ + --manifest-path src-tauri/Cargo.toml \ + --profile dhat \ + --features dhat-heap \ + --bin org2 \ + -- "$@" +) + +if [[ -f "${PROFILE_FILE}" ]]; then + echo "DHAT profile saved: ${PROFILE_FILE}" +else + echo "DHAT did not write a profile. Make sure ORGII exited normally instead of being force-killed." >&2 + exit 1 +fi diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index 089601adb0..ff8b69d13e 100644 --- a/src-tauri/.cargo/config.toml +++ b/src-tauri/.cargo/config.toml @@ -67,3 +67,15 @@ rustflags = [ # # [target.aarch64-apple-darwin] # rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/bin/ld64.lld"] + +# ── ts-rs codegen: large-int binding ───────────────────────────────────────── +# ts-rs (optional, feature-gated `ts-rs`) maps i64/u64/i128/u128 to `bigint` by +# default. The frontend cross-end contract (hand-written Zod) uses `number`, and +# every ORGII i64 field in practice (token counts, ms timestamps, byte sizes, +# row counts) stays well under Number.MAX_SAFE_INTEGER (2^53). Emit `number` +# globally so generated TS matches the existing wire contract with zero +# per-field annotations. Fields that genuinely need full 64-bit range can opt +# back per-field via `#[ts(type = "bigint")]`. Only consumed by ts-rs +# generation (feature on); no effect on normal builds. +[env] +TS_RS_LARGE_INT = "number" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2c2c2337de..e001261b01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -645,6 +654,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -1860,6 +1884,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot 0.12.5", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "digest" version = "0.10.7" @@ -2773,6 +2813,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -3396,7 +3442,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -4449,6 +4495,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.1" @@ -5053,6 +5105,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -5150,6 +5211,7 @@ dependencies = [ "database", "db_browser", "db_clients", + "dhat", "dirs", "dispatch2", "file_ops", @@ -5297,7 +5359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -6667,6 +6729,12 @@ dependencies = [ "zip 7.2.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7267,6 +7335,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ts-rs", ] [[package]] @@ -8689,6 +8758,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" @@ -9124,6 +9199,7 @@ dependencies = [ "serde_json", "tauri", "tokio", + "ts-rs", ] [[package]] @@ -9213,6 +9289,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -9909,7 +10007,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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef475abb51..c176c32484 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -186,6 +186,9 @@ cc = "1.0" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" +# Opt-in Rust heap profiling for the desktop binary. This dependency is not +# linked unless `--features dhat-heap` is explicitly requested. +dhat = { version = "0.3.3", optional = true } tauri = { workspace = true } tauri-plugin-shell = "2" tauri-plugin-fs = { version = "=2.4.5", features = ["watch"] } @@ -528,6 +531,9 @@ libc = "0.2" [features] default = [] +# Developer-only heap profiler. Never enable this in production builds: DHAT +# replaces the process-wide allocator and records every Rust heap allocation. +dhat-heap = ["dep:dhat"] # E2E WebDriver automation plugin (debug/test only). Enable with --features webdriver. webdriver = ["dep:tauri-plugin-webdriver-automation"] semantic-search = ["advanced_search/semantic-search"] @@ -613,6 +619,14 @@ codegen-units = 4 panic = "abort" strip = true +# Optimized, symbolized build used only by scripts/dev/profile-rust-heap.sh. +# Keeping this separate preserves the size and performance of normal release +# artifacts while giving DHAT useful allocation backtraces. +[profile.dhat] +inherits = "release" +debug = 1 +strip = false + # Development profile - faster builds. # # History: this used to set `[profile.dev.package."*"] opt-level = 2`, which diff --git a/src-tauri/crates/session-persistence/Cargo.toml b/src-tauri/crates/session-persistence/Cargo.toml index 96d4ae4d25..1800fd31da 100644 --- a/src-tauri/crates/session-persistence/Cargo.toml +++ b/src-tauri/crates/session-persistence/Cargo.toml @@ -45,3 +45,7 @@ tracing = "0.1" # Error derive macros — `IntentError` in `turn_intents`. thiserror = { workspace = true } +ts-rs = { version = "12.0.1", optional = true } + +[features] +ts-rs = ["dep:ts-rs"] diff --git a/src-tauri/crates/session-persistence/bindings/CacheStats.ts b/src-tauri/crates/session-persistence/bindings/CacheStats.ts new file mode 100644 index 0000000000..e91561909e --- /dev/null +++ b/src-tauri/crates/session-persistence/bindings/CacheStats.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Cache statistics + */ +export type CacheStats = { totalSessions: number, totalEvents: number, dbSizeBytes: number, }; diff --git a/src-tauri/crates/session-persistence/src/types.rs b/src-tauri/crates/session-persistence/src/types.rs index 7c452595af..44e26554fc 100644 --- a/src-tauri/crates/session-persistence/src/types.rs +++ b/src-tauri/crates/session-persistence/src/types.rs @@ -72,6 +72,7 @@ pub struct CrossSessionSearchHit { /// Cache statistics #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct CacheStats { pub total_sessions: i64, diff --git a/src-tauri/crates/transport/Cargo.toml b/src-tauri/crates/transport/Cargo.toml index f6c621ff2d..3a3690a134 100644 --- a/src-tauri/crates/transport/Cargo.toml +++ b/src-tauri/crates/transport/Cargo.toml @@ -21,7 +21,11 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1" tauri = { workspace = true } tokio = { workspace = true } +ts-rs = { version = "12.0.1", optional = true } [dev-dependencies] tokio = { workspace = true } chrono = { workspace = true } + +[features] +ts-rs = ["dep:ts-rs"] diff --git a/src-tauri/crates/transport/bindings/AgentEvent.ts b/src-tauri/crates/transport/bindings/AgentEvent.ts new file mode 100644 index 0000000000..0377cb3118 --- /dev/null +++ b/src-tauri/crates/transport/bindings/AgentEvent.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Agent event types for session lifecycle + */ +export type AgentEvent = { "type": "sessionCreated", "payload": { sessionId: string, sessionName: string, agentType: string, workspacePath: string | null, } } | { "type": "sessionDeleted", "payload": { sessionId: string, } } | { "type": "dialogTurnStarted", "payload": { sessionId: string, turnId: string, turnIndex: number, userInput: string, } } | { "type": "dialogTurnCompleted", "payload": { sessionId: string, turnId: string, } } | { "type": "dialogTurnCancelled", "payload": { sessionId: string, turnId: string, } } | { "type": "dialogTurnFailed", "payload": { sessionId: string, turnId: string, error: string, } } | { "type": "tokenUsageUpdated", "payload": { sessionId: string, turnId: string, modelId: string, inputTokens: number, outputTokens: number, totalTokens: number, } } | { "type": "sessionStateChanged", "payload": { sessionId: string, newState: string, } }; diff --git a/src-tauri/crates/transport/bindings/TextChunk.ts b/src-tauri/crates/transport/bindings/TextChunk.ts new file mode 100644 index 0000000000..8ec5b0a117 --- /dev/null +++ b/src-tauri/crates/transport/bindings/TextChunk.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Text chunk data structure for streaming + */ +export type TextChunk = { sessionId: string, turnId: string, roundId: string, text: string, timestamp: number, contentType: string | null, isComplete: boolean, }; diff --git a/src-tauri/crates/transport/bindings/ToolEvent.ts b/src-tauri/crates/transport/bindings/ToolEvent.ts new file mode 100644 index 0000000000..632f486fa3 --- /dev/null +++ b/src-tauri/crates/transport/bindings/ToolEvent.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolEventType } from "./ToolEventType"; + +/** + * Tool event for tracking tool execution + */ +export type ToolEvent = { sessionId: string, turnId: string, toolId: string, toolName: string, eventType: ToolEventType, params: unknown, result: unknown, error: string | null, durationMs: number | null, }; diff --git a/src-tauri/crates/transport/bindings/ToolEventType.ts b/src-tauri/crates/transport/bindings/ToolEventType.ts new file mode 100644 index 0000000000..140d2852e0 --- /dev/null +++ b/src-tauri/crates/transport/bindings/ToolEventType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Tool event types + */ +export type ToolEventType = "started" | "params_detected" | "params_complete" | "completed" | "failed" | "progress" | "stream_chunk" | "confirmation_needed"; diff --git a/src-tauri/crates/transport/src/adapters/mock.rs b/src-tauri/crates/transport/src/adapters/mock.rs index 0632c7ef3a..3f0762ae65 100644 --- a/src-tauri/crates/transport/src/adapters/mock.rs +++ b/src-tauri/crates/transport/src/adapters/mock.rs @@ -60,138 +60,23 @@ impl MockTransportAdapter { #[async_trait] impl TransportAdapter for MockTransportAdapter { async fn emit_agent_event(&self, session_id: &str, event: AgentEvent) -> anyhow::Result<()> { - let (event_name, payload) = match event { - AgentEvent::SessionCreated { - session_id: sid, - session_name, - agent_type, - workspace_path, - } => ( - "agent://session-created", - serde_json::json!({ - "sessionId": sid, - "sessionName": session_name, - "agentType": agent_type, - "workspacePath": workspace_path, - }), - ), - AgentEvent::SessionDeleted { session_id: sid } => ( - "agent://session-deleted", - serde_json::json!({ - "sessionId": sid, - }), - ), - AgentEvent::DialogTurnStarted { - session_id: sid, - turn_id, - turn_index, - user_input, - } => ( - "agent://dialog-turn-started", - serde_json::json!({ - "sessionId": sid, - "turnId": turn_id, - "turnIndex": turn_index, - "userInput": user_input, - }), - ), - AgentEvent::DialogTurnCompleted { - session_id: sid, - turn_id, - } => ( - "agent://dialog-turn-completed", - serde_json::json!({ - "sessionId": sid, - "turnId": turn_id, - }), - ), - AgentEvent::DialogTurnCancelled { - session_id: sid, - turn_id, - } => ( - "agent://dialog-turn-cancelled", - serde_json::json!({ - "sessionId": sid, - "turnId": turn_id, - }), - ), - AgentEvent::DialogTurnFailed { - session_id: sid, - turn_id, - error, - } => ( - "agent://dialog-turn-failed", - serde_json::json!({ - "sessionId": sid, - "turnId": turn_id, - "error": error, - }), - ), - AgentEvent::TokenUsageUpdated { - session_id: sid, - turn_id, - model_id, - input_tokens, - output_tokens, - total_tokens, - } => ( - "agent://token-usage-updated", - serde_json::json!({ - "sessionId": sid, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - }), - ), - AgentEvent::SessionStateChanged { - session_id: sid, - new_state, - } => ( - "agent://session-state-changed", - serde_json::json!({ - "sessionId": sid, - "newState": new_state, - }), - ), - }; - - self.capture_event(event_name, payload, session_id).await; + // Mirror the Tauri adapter: serialize the typed event straight to the + // wire so the mock captures exactly what production emits. + let payload = serde_json::to_value(&event)?; + self.capture_event("agent://event", payload, session_id) + .await; Ok(()) } async fn emit_text_chunk(&self, session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - let payload = serde_json::json!({ - "sessionId": chunk.session_id, - "turnId": chunk.turn_id, - "roundId": chunk.round_id, - "text": chunk.text, - "timestamp": chunk.timestamp, - "contentType": chunk.content_type, - "isComplete": chunk.is_complete, - }); - + let payload = serde_json::to_value(&chunk)?; self.capture_event("agent://text-chunk", payload, session_id) .await; Ok(()) } async fn emit_tool_event(&self, session_id: &str, event: ToolEvent) -> anyhow::Result<()> { - let payload = serde_json::json!({ - "sessionId": event.session_id, - "turnId": event.turn_id, - "toolEvent": { - "tool_id": event.tool_id, - "tool_name": event.tool_name, - "event_type": event.event_type, - "params": event.params, - "result": event.result, - "error": event.error, - "duration_ms": event.duration_ms, - } - }); - + let payload = serde_json::to_value(&event)?; self.capture_event("agent://tool-event", payload, session_id) .await; Ok(()) diff --git a/src-tauri/crates/transport/src/adapters/tauri.rs b/src-tauri/crates/transport/src/adapters/tauri.rs index 9cfd0ad261..2cebabfc7c 100644 --- a/src-tauri/crates/transport/src/adapters/tauri.rs +++ b/src-tauri/crates/transport/src/adapters/tauri.rs @@ -1,8 +1,9 @@ //! Tauri transport adapter //! //! Uses Tauri's app.emit() system to send events to frontend. -//! Maintains compatibility with current implementation while providing -//! the abstraction layer for future extensibility. +//! Emits the typed event structs directly (serde is the single source of truth +//! for the wire shape), so the emitted payload always matches the ts-rs-generated +//! TypeScript type — no hand-maintained field remapping that can drift. use crate::traits::{AgentEvent, TextChunk, ToolEvent, TransportAdapter}; use async_trait::async_trait; @@ -34,154 +35,21 @@ impl fmt::Debug for TauriTransportAdapter { #[async_trait] impl TransportAdapter for TauriTransportAdapter { async fn emit_agent_event(&self, _session_id: &str, event: AgentEvent) -> anyhow::Result<()> { - match event { - AgentEvent::SessionCreated { - session_id, - session_name, - agent_type, - workspace_path, - } => { - self.app_handle.emit( - "agent://session-created", - json!({ - "sessionId": session_id, - "sessionName": session_name, - "agentType": agent_type, - "workspacePath": workspace_path, - }), - )?; - } - AgentEvent::SessionDeleted { session_id } => { - self.app_handle.emit( - "agent://session-deleted", - json!({ - "sessionId": session_id, - }), - )?; - } - AgentEvent::DialogTurnStarted { - session_id, - turn_id, - turn_index, - user_input, - } => { - self.app_handle.emit( - "agent://dialog-turn-started", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": turn_index, - "userInput": user_input, - }), - )?; - } - AgentEvent::DialogTurnCompleted { - session_id, - turn_id, - } => { - self.app_handle.emit( - "agent://dialog-turn-completed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - }), - )?; - } - AgentEvent::DialogTurnCancelled { - session_id, - turn_id, - } => { - self.app_handle.emit( - "agent://dialog-turn-cancelled", - json!({ - "sessionId": session_id, - "turnId": turn_id, - }), - )?; - } - AgentEvent::DialogTurnFailed { - session_id, - turn_id, - error, - } => { - self.app_handle.emit( - "agent://dialog-turn-failed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "error": error, - }), - )?; - } - AgentEvent::TokenUsageUpdated { - session_id, - turn_id, - model_id, - input_tokens, - output_tokens, - total_tokens, - } => { - self.app_handle.emit( - "agent://token-usage-updated", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - }), - )?; - } - AgentEvent::SessionStateChanged { - session_id, - new_state, - } => { - self.app_handle.emit( - "agent://session-state-changed", - json!({ - "sessionId": session_id, - "newState": new_state, - }), - )?; - } - } + // Serialize the typed event directly. `AgentEvent`'s serde config + // (`tag = "type"`, `content = "payload"`, camelCase variants/fields) is + // the single source of truth for the wire shape — identical to what + // ts-rs generates for the `AgentEvent` type, so the contract can't drift. + self.app_handle.emit("agent://event", &event)?; Ok(()) } async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - self.app_handle.emit( - "agent://text-chunk", - json!({ - "sessionId": chunk.session_id, - "turnId": chunk.turn_id, - "roundId": chunk.round_id, - "text": chunk.text, - "timestamp": chunk.timestamp, - "contentType": chunk.content_type, - "isComplete": chunk.is_complete, - }), - )?; + self.app_handle.emit("agent://text-chunk", &chunk)?; Ok(()) } async fn emit_tool_event(&self, _session_id: &str, event: ToolEvent) -> anyhow::Result<()> { - self.app_handle.emit( - "agent://tool-event", - json!({ - "sessionId": event.session_id, - "turnId": event.turn_id, - "toolEvent": { - "tool_id": event.tool_id, - "tool_name": event.tool_name, - "event_type": event.event_type, - "params": event.params, - "result": event.result, - "error": event.error, - "duration_ms": event.duration_ms, - } - }), - )?; + self.app_handle.emit("agent://tool-event", &event)?; Ok(()) } diff --git a/src-tauri/crates/transport/src/tests/transport_tests.rs b/src-tauri/crates/transport/src/tests/transport_tests.rs index 41dfa808c2..b1dd4078b3 100644 --- a/src-tauri/crates/transport/src/tests/transport_tests.rs +++ b/src-tauri/crates/transport/src/tests/transport_tests.rs @@ -34,18 +34,23 @@ async fn test_mock_transport_adapter_agent_events() { let events = adapter.get_captured_events().await; assert_eq!(events.len(), 2); - assert_eq!(events[0].event_name, "agent://session-created"); + // Both events now emit under a single tagged channel; the variant is + // carried by the serde `type` discriminator (camelCase) instead of a + // per-variant event name. + assert_eq!(events[0].event_name, "agent://event"); assert_eq!(events[0].session_id, session_id); + assert_eq!(events[0].payload["type"], "sessionCreated"); - assert_eq!(events[1].event_name, "agent://dialog-turn-started"); + assert_eq!(events[1].event_name, "agent://event"); assert_eq!(events[1].session_id, session_id); - // Verify payload structure + // Verify tagged payload structure (camelCase fields nested under `payload`). let turn_payload = &events[1].payload; - assert_eq!(turn_payload["sessionId"], session_id); - assert_eq!(turn_payload["turnId"], "turn-1"); - assert_eq!(turn_payload["turnIndex"], 1); - assert_eq!(turn_payload["userInput"], "Hello, world!"); + assert_eq!(turn_payload["type"], "dialogTurnStarted"); + assert_eq!(turn_payload["payload"]["sessionId"], session_id); + assert_eq!(turn_payload["payload"]["turnId"], "turn-1"); + assert_eq!(turn_payload["payload"]["turnIndex"], 1); + assert_eq!(turn_payload["payload"]["userInput"], "Hello, world!"); } #[tokio::test] @@ -104,10 +109,11 @@ async fn test_transport_emitter_tool_events() { assert_eq!(events.len(), 1); assert_eq!(events[0].event_name, "agent://tool-event"); - let tool_data = &events[0].payload["toolEvent"]; - assert_eq!(tool_data["tool_name"], "file_write"); - assert_eq!(tool_data["event_type"], "started"); - assert_eq!(tool_data["params"]["file_path"], "test.rs"); + // Flat, camelCase-serialized `ToolEvent` (no bespoke `toolEvent` nesting). + let payload = &events[0].payload; + assert_eq!(payload["toolName"], "file_write"); + assert_eq!(payload["eventType"], "started"); + assert_eq!(payload["params"]["file_path"], "test.rs"); } #[tokio::test] diff --git a/src-tauri/crates/transport/src/traits.rs b/src-tauri/crates/transport/src/traits.rs index 30b4154a8d..ea9a576da6 100644 --- a/src-tauri/crates/transport/src/traits.rs +++ b/src-tauri/crates/transport/src/traits.rs @@ -47,7 +47,13 @@ pub trait TransportAdapter: Send + Sync + Debug { /// Agent event types for session lifecycle #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "payload")] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))] +#[serde( + tag = "type", + content = "payload", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum AgentEvent { SessionCreated { session_id: String, @@ -93,6 +99,8 @@ pub enum AgentEvent { /// Text chunk data structure for streaming #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] pub struct TextChunk { pub session_id: String, pub turn_id: String, @@ -105,13 +113,19 @@ pub struct TextChunk { /// Tool event for tracking tool execution #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] pub struct ToolEvent { pub session_id: String, pub turn_id: String, pub tool_id: String, pub tool_name: String, pub event_type: ToolEventType, + // Opaque tool payloads: ts-rs cannot statically type `serde_json::Value` + // without the `serde-json-impl` feature, so they surface as `unknown`. + #[cfg_attr(feature = "ts-rs", ts(type = "unknown"))] pub params: Option, + #[cfg_attr(feature = "ts-rs", ts(type = "unknown"))] pub result: Option, pub error: Option, pub duration_ms: Option, @@ -119,6 +133,7 @@ pub struct ToolEvent { /// Tool event types #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "snake_case")] pub enum ToolEventType { Started, diff --git a/src-tauri/src/dhat_profiling.rs b/src-tauri/src/dhat_profiling.rs new file mode 100644 index 0000000000..8ee2b4e127 --- /dev/null +++ b/src-tauri/src/dhat_profiling.rs @@ -0,0 +1,226 @@ +//! Lifecycle control for the opt-in DHAT heap profiler. +//! +//! Tauri's `Application::run()` exits the process directly instead of +//! returning to `main`, so a profiler guard owned by `main` is never dropped. +//! Keep the guard here and finish it from the final `RunEvent::Exit` instead. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::Duration; + +const DEFAULT_START_DELAY_SECS: u64 = 15; +const MAX_START_DELAY_SECS: u64 = 60 * 60; + +enum ProfilerState { + NotScheduled, + Scheduled, + Running(dhat::Profiler), + Finished, +} + +static PROFILER_STATE: OnceLock> = OnceLock::new(); +static PROFILING_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// Global allocator that bypasses DHAT until the delayed profiling window. +/// +/// Allocations made before profiling can safely be freed while profiling is +/// active: DHAT explicitly ignores deallocations for blocks it did not record. +/// The inverse is also safe because DHAT ultimately delegates storage to the +/// system allocator. +pub struct DhatAllocator; + +impl DhatAllocator { + pub const fn new() -> Self { + Self + } + + fn is_active() -> bool { + PROFILING_ACTIVE.load(Ordering::Acquire) + } +} + +// SAFETY: both delegated allocators use the platform system allocator for the +// actual storage. Switching changes only whether DHAT records the operation; +// it does not change allocation layout or which allocator owns the pointer. +unsafe impl GlobalAlloc for DhatAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if Self::is_active() { + unsafe { GlobalAlloc::alloc(&dhat::Alloc, layout) } + } else { + unsafe { GlobalAlloc::alloc(&System, layout) } + } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if Self::is_active() { + unsafe { GlobalAlloc::dealloc(&dhat::Alloc, ptr, layout) } + } else { + unsafe { GlobalAlloc::dealloc(&System, ptr, layout) } + } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if Self::is_active() { + unsafe { GlobalAlloc::realloc(&dhat::Alloc, ptr, layout, new_size) } + } else { + unsafe { GlobalAlloc::realloc(&System, ptr, layout, new_size) } + } + } +} + +fn profiler_state() -> &'static Mutex { + PROFILER_STATE.get_or_init(|| Mutex::new(ProfilerState::NotScheduled)) +} + +fn lock_profiler_state() -> MutexGuard<'static, ProfilerState> { + profiler_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn parse_start_delay_secs(value: Option<&str>) -> u64 { + value + .and_then(|raw| raw.parse::().ok()) + .filter(|seconds| *seconds <= MAX_START_DELAY_SECS) + .unwrap_or(DEFAULT_START_DELAY_SECS) +} + +fn configured_start_delay() -> Duration { + let raw = std::env::var("ORGII_DHAT_START_DELAY_SECS").ok(); + let seconds = parse_start_delay_secs(raw.as_deref()); + if let Some(raw) = raw.filter(|raw| raw.parse::().ok() != Some(seconds)) { + eprintln!("[dhat] ignoring invalid ORGII_DHAT_START_DELAY_SECS={raw:?}; using {seconds}s"); + } + Duration::from_secs(seconds) +} + +fn output_path() -> PathBuf { + std::env::var_os("ORGII_DHAT_OUTPUT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("dhat-heap.json")) +} + +fn start_profiler() -> Result { + let output_path = output_path(); + if let Some(parent) = output_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create DHAT output directory {}: {error}", + parent.display() + ) + })?; + } + + let profiler = dhat::Profiler::builder() + .file_name(output_path.clone()) + .build(); + PROFILING_ACTIVE.store(true, Ordering::Release); + eprintln!( + "[dhat] Rust heap profiling started; output will be written to {} on exit", + output_path.display() + ); + Ok(profiler) +} + +/// Schedule one profiler start after Tauri's backend setup has completed. +/// +/// The one-shot delay lets the WebView and normal startup restoration settle +/// before DHAT begins capturing allocation backtraces. Repeated calls are +/// ignored, and the worker exits immediately after attempting the start. +pub(crate) fn schedule_from_env() { + { + let mut state = lock_profiler_state(); + if !matches!(*state, ProfilerState::NotScheduled) { + return; + } + *state = ProfilerState::Scheduled; + } + + let delay = configured_start_delay(); + eprintln!( + "[dhat] Rust heap profiling will start in {}s; wait for the 'profiling started' message before testing", + delay.as_secs() + ); + + if let Err(error) = std::thread::Builder::new() + .name("orgii-dhat-start".to_string()) + .spawn(move || { + if !delay.is_zero() { + std::thread::sleep(delay); + } + + let mut state = lock_profiler_state(); + if !matches!(*state, ProfilerState::Scheduled) { + return; + } + + match start_profiler() { + Ok(profiler) => *state = ProfilerState::Running(profiler), + Err(error) => { + *state = ProfilerState::Finished; + eprintln!("[dhat] failed to start Rust heap profiling: {error}"); + } + } + }) + { + *lock_profiler_state() = ProfilerState::Finished; + eprintln!("[dhat] failed to spawn delayed profiler worker: {error}"); + } +} + +/// Stop profiling and synchronously write the JSON profile exactly once. +/// +/// This must run from Tauri's final `RunEvent::Exit`, before Tauri terminates +/// the process with `std::process::exit` and skips ordinary Rust destructors. +pub(crate) fn finish() { + let previous = { + let mut state = lock_profiler_state(); + std::mem::replace(&mut *state, ProfilerState::Finished) + }; + + match previous { + ProfilerState::Running(profiler) => { + PROFILING_ACTIVE.store(false, Ordering::Release); + eprintln!("[dhat] finalizing Rust heap profile..."); + drop(profiler); + eprintln!("[dhat] Rust heap profile finalized"); + } + ProfilerState::Scheduled => { + eprintln!("[dhat] app exited before delayed heap profiling started"); + } + ProfilerState::NotScheduled | ProfilerState::Finished => {} + } +} + +#[cfg(test)] +mod tests { + use super::{parse_start_delay_secs, DEFAULT_START_DELAY_SECS, MAX_START_DELAY_SECS}; + + #[test] + fn start_delay_accepts_bounded_seconds() { + assert_eq!(parse_start_delay_secs(Some("0")), 0); + assert_eq!(parse_start_delay_secs(Some("45")), 45); + assert_eq!( + parse_start_delay_secs(Some(&MAX_START_DELAY_SECS.to_string())), + MAX_START_DELAY_SECS + ); + } + + #[test] + fn start_delay_rejects_invalid_or_unbounded_values() { + assert_eq!(parse_start_delay_secs(None), DEFAULT_START_DELAY_SECS); + assert_eq!( + parse_start_delay_secs(Some("not-a-number")), + DEFAULT_START_DELAY_SECS + ); + assert_eq!( + parse_start_delay_secs(Some(&(MAX_START_DELAY_SECS + 1).to_string())), + DEFAULT_START_DELAY_SECS + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55ff73d948..751fd7f9c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,9 @@ pub mod api; pub mod app_update; // Channel-aware (stable/beta) app update checks pub mod benchmark; pub mod cli_managed_proxy; +#[cfg(feature = "dhat-heap")] +#[doc(hidden)] +pub mod dhat_profiling; pub mod infrastructure; // In-tree-only cross-cutting infrastructure (paths, platform, archive, index_manager, jsonrpc, housekeeping). Leaf pieces live in their own workspace crates. pub mod orgtrack; mod runtime_instance; @@ -978,6 +981,9 @@ pub fn run() { } } + #[cfg(feature = "dhat-heap")] + crate::dhat_profiling::schedule_from_env(); + if dev_startup_debug_enabled() { app.listen("orgii-startup-first-paint", |event| { println!("[TauriStartup] frontend first paint ready {}", event.payload()); @@ -1114,6 +1120,10 @@ pub fn run() { .state::<::terminal::pty_commands::pty::PtyState>() .shutdown_kill_all(); } + tauri::RunEvent::Exit => { + #[cfg(feature = "dhat-heap")] + crate::dhat_profiling::finish(); + } _ => {} } }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 558034d977..12a1cf54b4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,11 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(feature = "dhat-heap")] +#[global_allocator] +static DHAT_ALLOCATOR: app_lib::dhat_profiling::DhatAllocator = + app_lib::dhat_profiling::DhatAllocator::new(); + fn main() { let mut args = std::env::args().skip(1); if args.next().as_deref() == Some("--session-provenance-hook") { diff --git a/src/api/tauri/rpc/__tests__/outputValidation.test.ts b/src/api/tauri/rpc/__tests__/outputValidation.test.ts new file mode 100644 index 0000000000..c78b8f7640 --- /dev/null +++ b/src/api/tauri/rpc/__tests__/outputValidation.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; + +import { + RpcError, + defineProcedure, + getRpcOutputValidationMode, + resetRpcOutputValidationMode, + rpcCall, + setRpcOutputValidationMode, +} from "../invoke"; + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})); + +const numberProc = defineProcedure("drift_probe") + .output(z.object({ n: z.number() })) + .build(); + +describe("typedInvoke output-validation modes", () => { + beforeEach(() => { + invokeMock.mockReset(); + resetRpcOutputValidationMode(); + window.__orgiiRpcOutputDrift = []; + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + resetRpcOutputValidationMode(); + vi.restoreAllMocks(); + }); + + it("defaults to warn in dev/test: logs, records drift, returns raw data (no throw)", async () => { + const consoleErrorSpy = vi.spyOn(console, "error"); + invokeMock.mockResolvedValue({ n: "not-a-number" }); + + const result = await rpcCall(numberProc); + + // Data is still returned so a schema mismatch never breaks the UI in warn mode. + expect(result).toEqual({ n: "not-a-number" }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[RPC:drift_probe] Output validation failed", + expect.any(Array), + "Raw:", + { n: "not-a-number" } + ); + expect(window.__orgiiRpcOutputDrift?.at(-1)?.command).toBe("drift_probe"); + }); + + it("off mode: skips validation entirely (no console.error, no drift record)", async () => { + setRpcOutputValidationMode("off"); + const consoleErrorSpy = vi.spyOn(console, "error"); + invokeMock.mockResolvedValue({ n: "bad" }); + + const result = await rpcCall(numberProc); + + expect(result).toEqual({ n: "bad" }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(window.__orgiiRpcOutputDrift ?? []).toHaveLength(0); + }); + + it("throw mode: raises RpcError on drift (and still logs + records)", async () => { + setRpcOutputValidationMode("throw"); + const consoleErrorSpy = vi.spyOn(console, "error"); + invokeMock.mockResolvedValue({ n: "bad" }); + + await expect(rpcCall(numberProc)).rejects.toBeInstanceOf(RpcError); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(window.__orgiiRpcOutputDrift?.at(-1)?.command).toBe("drift_probe"); + }); + + it("throw mode: passes valid output through without error", async () => { + setRpcOutputValidationMode("throw"); + const consoleErrorSpy = vi.spyOn(console, "error"); + invokeMock.mockResolvedValue({ n: 42 }); + + const result = await rpcCall(numberProc); + + expect(result).toEqual({ n: 42 }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(window.__orgiiRpcOutputDrift ?? []).toHaveLength(0); + }); + + it("mode getter/setter/reset round-trip", () => { + expect(getRpcOutputValidationMode()).toBe("warn"); + setRpcOutputValidationMode("throw"); + expect(getRpcOutputValidationMode()).toBe("throw"); + setRpcOutputValidationMode("off"); + expect(getRpcOutputValidationMode()).toBe("off"); + resetRpcOutputValidationMode(); + expect(getRpcOutputValidationMode()).toBe("warn"); + }); + + it("caps the drift ring buffer at 200 records", async () => { + invokeMock.mockResolvedValue({ n: "bad" }); + for (let i = 0; i < 205; i += 1) { + await rpcCall(numberProc); + } + expect(window.__orgiiRpcOutputDrift?.length).toBe(200); + }); +}); diff --git a/src/api/tauri/rpc/invoke.ts b/src/api/tauri/rpc/invoke.ts index 5081e1d801..e231a54d01 100644 --- a/src/api/tauri/rpc/invoke.ts +++ b/src/api/tauri/rpc/invoke.ts @@ -26,6 +26,68 @@ export class RpcError extends Error { } } +// ============================================================================ +// Output-validation policy +// ============================================================================ + +/** + * How `typedInvoke` reacts when a Rust response fails its `output` Zod schema + * (i.e. the TS<->Rust contract has drifted): + * + * - `"off"` — skip output validation entirely (lowest overhead). + * - `"warn"` — validate, `console.error` + record the drift, but still return + * the raw data so the UI keeps working. + * - `"throw"` — validate and throw a {@link RpcError} on mismatch. Intended for + * CI / E2E runs so contract drift fails the build instead of + * silently slipping through. + * + * Default preserves the historical behavior: `"warn"` in dev/test, `"off"` in + * production (output validation is not free on large payloads). Flip it with + * {@link setRpcOutputValidationMode} — e.g. an E2E harness sets `"throw"`. + */ +export type RpcOutputValidationMode = "off" | "warn" | "throw"; + +const DEFAULT_OUTPUT_VALIDATION_MODE: RpcOutputValidationMode = + process.env.NODE_ENV === "production" ? "off" : "warn"; + +let outputValidationMode: RpcOutputValidationMode = + DEFAULT_OUTPUT_VALIDATION_MODE; + +/** Override how output-schema drift is handled process-wide. */ +export function setRpcOutputValidationMode( + mode: RpcOutputValidationMode +): void { + outputValidationMode = mode; +} + +/** Current output-validation mode (see {@link RpcOutputValidationMode}). */ +export function getRpcOutputValidationMode(): RpcOutputValidationMode { + return outputValidationMode; +} + +/** Reset the output-validation mode to the env-derived default. */ +export function resetRpcOutputValidationMode(): void { + outputValidationMode = DEFAULT_OUTPUT_VALIDATION_MODE; +} + +const MAX_OUTPUT_DRIFT_RECORDS = 200; + +/** + * Record an output-schema drift into a window ring buffer so E2E / CI (and the + * runtime diagnostics panel) can observe contract drift without spying on + * `console`. No-op outside a browser/webview context. + */ +function recordOutputDrift(command: string, issues: unknown): void { + if (typeof window === "undefined") return; + window.__orgiiRpcOutputDrift ??= []; + window.__orgiiRpcOutputDrift.push({ command, issues, at: Date.now() }); + const overflow = + window.__orgiiRpcOutputDrift.length - MAX_OUTPUT_DRIFT_RECORDS; + if (overflow > 0) { + window.__orgiiRpcOutputDrift.splice(0, overflow); + } +} + // ============================================================================ // Procedure definition // ============================================================================ @@ -61,6 +123,17 @@ declare global { interface Window { __orgiiE2ERpcCounts?: Record; __orgiiE2ERpcLog?: Array<{ command: string; at: number }>; + /** + * Ring buffer of the most recent output-validation drifts (TS schema vs the + * actual Rust response). Populated whenever output validation runs and + * fails, regardless of mode, so E2E / CI can assert drift without spying on + * `console`. Bounded to avoid unbounded growth on a long-lived session. + */ + __orgiiRpcOutputDrift?: Array<{ + command: string; + issues: unknown; + at: number; + }>; } } @@ -128,9 +201,11 @@ export async function typedInvoke< // Optional transform (snake_case → camelCase, etc.) const transformed = transform ? transform(raw) : raw; - // Validate output in dev (skip in prod for perf) - const isDev = process.env.NODE_ENV !== "production"; - if (output && isDev) { + // Validate output against the configured drift policy. Default preserves the + // historical behavior (dev/test: "warn", prod: "off"), but the mode is now + // switchable so CI/E2E can enforce ("throw") and prod can opt into observing + // ("warn") — turning silent TS<->Rust contract drift into a catchable signal. + if (output && outputValidationMode !== "off") { const parsed = output.safeParse(transformed); if (!parsed.success) { // Raw console.error kept intentionally: asserted by rpc/router.test.ts. @@ -140,7 +215,19 @@ export async function typedInvoke< "Raw:", raw ); - // In dev, still return the data so the app doesn't break — just warn loudly + recordOutputDrift(command, parsed.error.issues); + if (outputValidationMode === "throw") { + throw new RpcError( + command, + `Output validation failed: ${JSON.stringify( + parsed.error.issues, + null, + 2 + )}`, + parsed.error + ); + } + // "warn": still return the data so the app doesn't break — just warn loudly. } }