diff --git a/Cargo.lock b/Cargo.lock index da305388..b9102512 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3035,6 +3035,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "ts-rs", "uuid", ] @@ -4696,6 +4697,28 @@ dependencies = [ "toml", ] +[[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 = "ttf-parser" version = "0.25.1" diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index a9b8184e..5b2ad758 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -4,8 +4,14 @@ version = "0.1.0" edition.workspace = true license.workspace = true +[features] +default = ["stream"] +stream = ["dep:tokio"] +typescript = ["dep:ts-rs"] + [dependencies] serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -tokio = { workspace = true, features = ["io-util"] } +tokio = { workspace = true, features = ["io-util"], optional = true } +ts-rs = { version = "12.0.1", optional = true } uuid = { workspace = true, features = ["serde"] } diff --git a/crates/protocol/examples/generate_typescript.rs b/crates/protocol/examples/generate_typescript.rs new file mode 100644 index 00000000..b5b3fdb9 --- /dev/null +++ b/crates/protocol/examples/generate_typescript.rs @@ -0,0 +1,16 @@ +#[cfg(feature = "typescript")] +fn main() -> Result<(), Box> { + let path = protocol::typescript::generated_typescript_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, protocol::typescript::generated_protocol_types())?; + println!("wrote {}", path.display()); + Ok(()) +} + +#[cfg(not(feature = "typescript"))] +fn main() { + eprintln!("enable the `typescript` feature to generate protocol TypeScript bindings"); + std::process::exit(2); +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index ee2e38f8..7dde4707 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1,4 +1,7 @@ +#[cfg(feature = "stream")] pub mod stream; +#[cfg(feature = "typescript")] +pub mod typescript; use std::path::PathBuf; @@ -21,6 +24,7 @@ fn is_false(value: &bool) -> bool { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum Method { Run { @@ -103,6 +107,7 @@ pub enum Method { /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// child Pod). #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PodEvent { /// Child finished one turn and is back to IDLE. @@ -175,6 +180,7 @@ impl PodEvent { /// placeholder into the LLM context so neither user nor LLM is blind to /// the dropped intent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Segment { /// Free-form text. The fallback every client can produce. @@ -266,6 +272,7 @@ impl Method { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum Event { /// A user input message was accepted, persisted as @@ -294,6 +301,7 @@ pub enum Event { /// One event per `LogEntry::SystemItem` commit. Disk-side and /// wire-side are 1:1. SystemItem { + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] item: serde_json::Value, }, /// A new self-driving cycle has begun (IDLE → active transition). @@ -453,6 +461,7 @@ pub enum Event { /// role-specific entry events (`SegmentRotated` / `SystemItem`) — /// there is no generic "every committed entry" broadcast. Snapshot { + #[cfg_attr(feature = "typescript", ts(type = "Array"))] entries: Vec, greeting: Greeting, #[serde(default)] @@ -471,6 +480,7 @@ pub enum Event { /// /// Payload is the JSON form of `session_store::LogEntry::SegmentStart`. SegmentRotated { + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] entry: serde_json::Value, }, /// Current Pod controller status. Broadcast on every controller-level @@ -495,6 +505,7 @@ pub enum Event { /// A rewind has truncated the authoritative session. `entries` is the /// retained session-log prefix clients should use to reseed display state. RewindApplied { + #[cfg_attr(feature = "typescript", ts(type = "Array"))] entries: Vec, input: Vec, summary: RewindSummary, @@ -503,14 +514,17 @@ pub enum Event { /// crate can evolve discovery fields without introducing a protocol /// dependency on session-store. PodsListed { + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] pods: serde_json::Value, }, /// Reply to `Method::RestorePod`. PodRestored { + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] result: serde_json::Value, }, /// Reply to `Method::RegisterPeer`. PeerRegistered { + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] result: serde_json::Value, }, Alert(Alert), @@ -530,6 +544,7 @@ pub enum Event { /// `new_segment_id` is the UUID of the freshly created session that /// replaced the old history. CompactDone { + #[cfg_attr(feature = "typescript", ts(type = "string"))] new_segment_id: uuid::Uuid, }, /// Compaction failed. The session is unchanged. @@ -546,6 +561,7 @@ pub enum Event { /// surfaced to the person driving the client. Keep messages short and /// human-readable. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct Alert { pub level: AlertLevel, pub source: AlertSource, @@ -555,6 +571,7 @@ pub struct Alert { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct MemoryWorkerEvent { pub worker: String, pub status: String, @@ -568,6 +585,7 @@ pub struct MemoryWorkerEvent { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum AlertLevel { Warn, @@ -575,6 +593,7 @@ pub enum AlertLevel { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum AlertSource { Pod, @@ -591,6 +610,7 @@ pub enum AlertSource { /// nailed down here so the TUI side can ship without waiting for /// the memory / workflow tickets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum CompletionKind { File, @@ -605,6 +625,7 @@ pub enum CompletionKind { /// keep a trailing `/` after a directory selection so the user can /// drill in without re-typing the prefix. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct CompletionEntry { pub value: String, #[serde(default)] @@ -612,12 +633,15 @@ pub struct CompletionEntry { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RewindTargetId { + #[cfg_attr(feature = "typescript", ts(type = "string"))] pub segment_id: uuid::Uuid, pub user_input_entry_index: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RewindTarget { pub id: RewindTargetId, pub expected_head_entries: usize, @@ -633,6 +657,7 @@ pub struct RewindTarget { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RewindSummary { pub truncated_to_entries: usize, pub discarded_entries: usize, @@ -647,6 +672,7 @@ pub struct RewindSummary { /// history. Finalized assistant items continue to come from ordinary snapshot /// entries. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct InFlightSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, @@ -659,6 +685,7 @@ impl InFlightSnapshot { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum InFlightBlock { Text { @@ -681,6 +708,7 @@ pub enum InFlightBlock { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum InFlightToolCallState { #[default] @@ -701,6 +729,7 @@ impl InFlightToolCallState { /// transmitted alongside `Event::Snapshot` so clients don't need /// their own view of the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct Greeting { pub pod_name: String, pub cwd: String, @@ -721,6 +750,7 @@ pub struct Greeting { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum PodStatus { #[default] @@ -730,6 +760,7 @@ pub enum PodStatus { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum TurnResult { Finished, @@ -743,6 +774,7 @@ pub enum TurnResult { /// notify message, pod event body) is delivered by the immediately /// following Turn entry, not by the marker itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum InvokeKind { /// `Method::Run` — a user submission. @@ -762,6 +794,7 @@ pub enum InvokeKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RunResult { Finished, @@ -775,6 +808,7 @@ pub enum RunResult { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum ErrorCode { AlreadyRunning, @@ -796,6 +830,7 @@ pub enum ErrorCode { /// A single allow or deny rule inside a scope configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ScopeRule { /// Target path. Must be absolute by the time a `Scope` is built from /// this rule — relative paths are resolved per-layer against the @@ -822,6 +857,7 @@ fn default_recursive() -> bool { /// everything below); deny rules cap the effective level **strictly /// below** the stated level. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "lowercase")] pub enum Permission { Read, diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs new file mode 100644 index 00000000..92a9f7ff --- /dev/null +++ b/crates/protocol/src/typescript.rs @@ -0,0 +1,97 @@ +use std::path::PathBuf; + +use ts_rs::{Config, TS}; + +use crate::{ + Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, + InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method, + Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult, + ScopeRule, Segment, TurnResult, +}; + +const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts"; + +/// Repository-relative destination for the Workspace web protocol bindings. +pub fn generated_typescript_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH) +} + +/// Render Workspace web TypeScript bindings for the Pod wire protocol DTOs. +/// +/// Rust DTOs in this crate remain the source of truth; this function is used by +/// both the checked-in artifact generator and the stale-output drift test. +pub fn generated_protocol_types() -> String { + let cfg = Config::new().with_large_int("number"); + let mut output = String::from( + "// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n\ + // Do not edit by hand. Rust DTO authority lives in `crates/protocol`.\n\ + // Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n", + ); + + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + + normalize_typescript(output) +} + +fn normalize_typescript(output: String) -> String { + let mut lines = output.lines().map(str::trim_end).collect::>(); + while lines.last() == Some(&"") { + lines.pop(); + } + lines.join("\n") + "\n" +} + +fn push_decl(cfg: &Config, output: &mut String) { + let decl = T::decl(cfg); + output.push_str(&export_decl(&decl)); + output.push_str("\n\n"); +} + +fn export_decl(decl: &str) -> String { + for prefix in ["type ", "interface ", "enum "] { + if decl.starts_with(prefix) { + return format!("export {decl}"); + } + } + decl.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_protocol_types_are_current() { + let expected = generated_protocol_types(); + let path = generated_typescript_path(); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + assert_eq!( + actual, expected, + "generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`" + ); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f687e977..3222cd09 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -262,11 +262,11 @@ async fn get_workspace(State(api): State) -> ApiResult, }; + +export type Greeting = { pod_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array, +/** + * Model context window in tokens. Always filled by the Pod greeting. + */ +context_window: number, +/** + * Estimated current session context tokens at connect time. + */ +context_tokens: number, }; + +export type Alert = { level: AlertLevel, source: AlertSource, message: string, +/** + * Milliseconds since the Unix epoch. + */ +timestamp_ms: number, }; + +export type MemoryWorkerEvent = { worker: string, status: string, run_id: string, trigger: string, reason: string, +/** + * Human-readable compact form for actionbar rendering. + */ +message: string, +/** + * Milliseconds since the Unix epoch. + */ +timestamp_ms: number, }; + +export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "knowledge_ref", slug: string, } | { "kind": "workflow_invoke", slug: string, } | { "kind": "unknown" }; + +export type PodEvent = { "kind": "turn_ended", pod_name: string, } | { "kind": "errored", pod_name: string, message: string, } | { "kind": "shut_down", pod_name: string, } | { "kind": "scope_sub_delegated", +/** + * Sub-delegating Pod (= the sender itself). + */ +parent_pod: string, +/** + * Name of the grandchild Pod. + */ +sub_pod: string, +/** + * Unix-socket path where the grandchild is reachable. + */ +sub_socket: string, +/** + * Scope delegated to the grandchild. + */ +scope: Array, }; + +export type Method = { "method": "run", "params": { input: Array, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "pod_event", "params": PodEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_pods" } | { "method": "restore_pod", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; + +export type Event = { "event": "user_message", "data": { segments: Array, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number, +/** + * The attempt that just failed. 1 origin. + */ +failed_attempt: number, max_attempts: number, wait_ms: number, elapsed_ms: number, status?: number | null, error: string, } } | { "event": "llm_continuation", "data": { llm_call: number, attempt: number, max_attempts: number, reason: string, } } | { "event": "text_delta", "data": { text: string, } } | { "event": "text_done", "data": { text: string, } } | { "event": "thinking_start" } | { "event": "thinking_delta", "data": { text: string, } } | { "event": "thinking_done", "data": { text: string, } } | { "event": "tool_call_start", "data": { id: string, name: string, } } | { "event": "tool_call_args_delta", "data": { id: string, json: string, } } | { "event": "tool_call_done", "data": { id: string, name: string, arguments: string, } } | { "event": "tool_result", "data": { id: string, +/** + * Short human-readable summary. Always present; used by clients + * that only want a 1-line rendering (e.g. collapsed views). + */ +summary: string, +/** + * Full tool output. Absent when the tool chose to return + * summary-only, or when the result was pruned. + */ +output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array, greeting: Greeting, status: PodStatus, +/** + * Unfinished model output that has already streamed in the current + * run but is not yet represented by committed snapshot entries. + */ +in_flight?: InFlightSnapshot, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: PodStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { entries: Array, input: Array, summary: RewindSummary, } } | { "event": "pods_listed", "data": { pods: unknown, } } | { "event": "pod_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 28e8fec9..8096d9c1 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -1,3 +1,9 @@ +export type { + Event as PodProtocolEvent, + Method as PodProtocolMethod, + Segment as PodProtocolSegment, +} from '$lib/generated/protocol'; + export type ExtensionPoint = { status: string; note: string;