diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index 089601adb..ff8b69d13 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 04747b6dc..4cc3a5b12 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7266,6 +7266,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ts-rs", ] [[package]] @@ -9123,6 +9124,7 @@ dependencies = [ "serde_json", "tauri", "tokio", + "ts-rs", ] [[package]] @@ -9212,6 +9214,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" diff --git a/src-tauri/crates/session-persistence/Cargo.toml b/src-tauri/crates/session-persistence/Cargo.toml index 96d4ae4d2..1800fd31d 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 000000000..a9bb976b7 --- /dev/null +++ b/src-tauri/crates/session-persistence/bindings/CacheStats.ts @@ -0,0 +1,10 @@ +// 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 7c452595a..44e26554f 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 f6c621ff2..3a3690a13 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 000000000..dd9d7b5d7 --- /dev/null +++ b/src-tauri/crates/transport/bindings/AgentEvent.ts @@ -0,0 +1,52 @@ +// 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 000000000..5c46ff8bc --- /dev/null +++ b/src-tauri/crates/transport/bindings/TextChunk.ts @@ -0,0 +1,14 @@ +// 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 000000000..91960723c --- /dev/null +++ b/src-tauri/crates/transport/bindings/ToolEvent.ts @@ -0,0 +1,17 @@ +// 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 000000000..397637a53 --- /dev/null +++ b/src-tauri/crates/transport/bindings/ToolEventType.ts @@ -0,0 +1,14 @@ +// 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 0632c7ef3..3f0762ae6 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 9cfd0ad26..2cebabfc7 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 41dfa808c..b1dd4078b 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 30b4154a8..ea9a576da 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/api/tauri/rpc/__tests__/outputValidation.test.ts b/src/api/tauri/rpc/__tests__/outputValidation.test.ts new file mode 100644 index 000000000..c78b8f764 --- /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 5081e1d80..e231a54d0 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. } }