protocol: generate workspace TypeScript types

This commit is contained in:
2026-06-23 15:08:22 +09:00
parent 746c51c701
commit a13fb6933b
9 changed files with 311 additions and 4 deletions
+7 -1
View File
@@ -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"] }
@@ -0,0 +1,16 @@
#[cfg(feature = "typescript")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
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);
}
+36
View File
@@ -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<unknown>"))]
entries: Vec<serde_json::Value>,
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<unknown>"))]
entries: Vec<serde_json::Value>,
input: Vec<Segment>,
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<InFlightBlock>,
@@ -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,
+97
View File
@@ -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::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<PodStatus>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<Segment>(&cfg, &mut output);
push_decl::<PodEvent>(&cfg, &mut output);
push_decl::<Method>(&cfg, &mut output);
push_decl::<Event>(&cfg, &mut output);
normalize_typescript(output)
}
fn normalize_typescript(output: String) -> String {
let mut lines = output.lines().map(str::trim_end).collect::<Vec<_>>();
while lines.last() == Some(&"") {
lines.pop();
}
lines.join("\n") + "\n"
}
fn push_decl<T: TS>(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`"
);
}
}