diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 8a48a0411..5cfe93f7d 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [features] default = ["stream"] stream = ["dep:tokio"] -typescript = ["dep:ts-rs"] +typescript = ["dep:ts-rs", "json-schema"] json-schema = ["dep:schemars"] [dependencies] diff --git a/crates/protocol/examples/generate_typescript.rs b/crates/protocol/examples/generate_typescript.rs index b5b3fdb97..dd90ec0db 100644 --- a/crates/protocol/examples/generate_typescript.rs +++ b/crates/protocol/examples/generate_typescript.rs @@ -1,11 +1,38 @@ #[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)?; + let check = std::env::args() + .skip(1) + .any(|argument| argument == "--check"); + let outputs = [ + ( + protocol::typescript::generated_typescript_path(), + protocol::typescript::generated_protocol_types(), + ), + ( + protocol::typescript::generated_runtime_validator_path(), + protocol::typescript::generated_protocol_validator(), + ), + ]; + + for (path, expected) in outputs { + if check { + let actual = std::fs::read_to_string(&path)?; + if actual != expected { + return Err(format!( + "generated protocol artifact is stale: {}; run without --check", + path.display() + ) + .into()); + } + println!("current {}", path.display()); + } else { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, expected)?; + println!("wrote {}", path.display()); + } } - std::fs::write(&path, protocol::typescript::generated_protocol_types())?; - println!("wrote {}", path.display()); Ok(()) } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index dc89f04f5..a9bcc7a5d 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -83,6 +83,7 @@ impl AuthenticatedInputSource { /// of an accepted id must retain the same command kind. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkerCommandEnvelope { pub command_id: u64, } @@ -95,6 +96,7 @@ impl WorkerCommandEnvelope { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerCommandKind { Resume, @@ -106,6 +108,7 @@ pub enum WorkerCommandKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerCommandDisposition { Accepted, @@ -116,6 +119,7 @@ pub enum WorkerCommandDisposition { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkerCommandAcknowledgement { pub command_id: u64, pub command: WorkerCommandKind, @@ -212,6 +216,7 @@ impl From for WorkerStateSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum Method { /// Durably accept typed input for immediate activation while the Worker is Idle. @@ -228,6 +233,7 @@ pub enum Method { /// Authenticated transport form of Submit. Trusted adapters replace /// public Submit before forwarding it to the Worker. #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] SubmitTracked { submission_request_id: String, input: Vec, @@ -245,6 +251,7 @@ pub enum Method { }, /// Authenticated transport form of Notify. #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] NotifyTracked { notification_request_id: String, message: String, @@ -344,6 +351,7 @@ pub enum Method { /// child Worker). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum WorkerEvent { /// Child finished one turn and is back to IDLE. @@ -685,6 +693,7 @@ impl Method { /// protocol subjects. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InternalWorkerKind { SubWorker, @@ -694,6 +703,7 @@ pub enum InternalWorkerKind { /// Stable parent-owned lifecycle for one compaction run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CompactionLifecycle { pub schema_version: u32, pub compaction_id: String, @@ -715,6 +725,7 @@ pub struct CompactionLifecycle { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionLifecycleState { Running, @@ -727,6 +738,7 @@ pub enum CompactionLifecycleState { /// `name` is display-only; `session_id` is the identity used by clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InternalWorkerRef { pub session_id: String, pub name: String, @@ -739,6 +751,7 @@ pub struct InternalWorkerRef { /// `Event::InternalWorker` updates that raced with parent snapshot assembly. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InternalWorkerSnapshot { pub worker: InternalWorkerRef, pub revision: u64, @@ -755,6 +768,7 @@ pub struct InternalWorkerSnapshot { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ToolResultDisposition { #[default] @@ -768,6 +782,7 @@ pub enum ToolResultDisposition { /// Durable acceptance result for one idempotent Submit request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubmissionDisposition { Started, @@ -780,6 +795,7 @@ pub enum SubmissionDisposition { /// provenance remain in the session log and are intentionally not exposed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PendingSubmissionSummary { pub submission_id: String, pub accepted_at_ms: u64, @@ -790,6 +806,7 @@ pub struct PendingSubmissionSummary { /// Revisioned session-owned FIFO projection used by snapshots and live events. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PendingSubmissionsSnapshot { pub revision: u64, #[serde(default)] @@ -807,6 +824,7 @@ pub struct PendingSubmissionsSnapshot { /// to replay the durable log format. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionSnapshot { #[serde(default)] pub pending_submissions: PendingSubmissionsSnapshot, @@ -815,6 +833,7 @@ pub struct SessionSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SessionEntryProvenance { HumanInput, @@ -829,6 +848,7 @@ pub enum SessionEntryProvenance { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionSnapshotEntry { /// Stable identity from durable history metadata, or a deterministic /// identity derived from the legacy segment and log position. @@ -844,6 +864,7 @@ pub struct SessionSnapshotEntry { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionSnapshotEntryData { UserInput { @@ -881,6 +902,7 @@ pub enum SessionSnapshotEntryData { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SessionMessageRole { User, @@ -889,6 +911,7 @@ pub enum SessionMessageRole { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionContentPart { Text { text: String }, @@ -897,6 +920,7 @@ pub enum SessionContentPart { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SessionToolAttachment { pub media_type: String, /// Base64-encoded durable attachment body. Public snapshots preserve the @@ -906,6 +930,7 @@ pub struct SessionToolAttachment { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum Event { /// Durable Submit acceptance. New submissions are accepted only while Idle, @@ -1233,6 +1258,7 @@ pub enum Event { /// human-readable. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Alert { pub level: AlertLevel, pub source: AlertSource, @@ -1243,6 +1269,7 @@ pub struct Alert { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct MemoryWorkerEvent { pub worker: String, pub status: String, @@ -1257,6 +1284,7 @@ pub struct MemoryWorkerEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum AlertLevel { Warn, @@ -1265,6 +1293,7 @@ pub enum AlertLevel { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum AlertSource { Worker, @@ -1278,6 +1307,7 @@ pub enum AlertSource { /// Mirrors the completion prefix sigil: `@` → `File`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompletionKind { File, @@ -1288,6 +1318,7 @@ pub enum CompletionKind { /// `value` is a path (file kind). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CompletionEntry { pub value: String, #[serde(default)] @@ -1296,14 +1327,17 @@ pub struct CompletionEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindTargetId { #[cfg_attr(feature = "typescript", ts(type = "string"))] + #[cfg_attr(feature = "json-schema", schemars(with = "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))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindTarget { pub id: RewindTargetId, pub expected_head_entries: usize, @@ -1320,6 +1354,7 @@ pub struct RewindTarget { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct RewindSummary { pub truncated_to_entries: usize, pub discarded_entries: usize, @@ -1330,6 +1365,7 @@ pub struct RewindSummary { /// state only and are never appended to Worker history. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CommandStatus { Running, @@ -1341,6 +1377,7 @@ pub enum CommandStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CommandStream { Stdout, @@ -1349,6 +1386,7 @@ pub enum CommandStream { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CommandStreamSlice { pub start_offset: u64, pub end_offset: u64, @@ -1358,6 +1396,7 @@ pub struct CommandStreamSlice { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct CommandSnapshot { pub command_id: String, pub tool_call_id: Option, @@ -1372,6 +1411,7 @@ pub struct CommandSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CommandEvent { Started { @@ -1399,6 +1439,7 @@ pub enum CommandEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionPhase { Preparing, @@ -1408,6 +1449,7 @@ pub enum CompactionPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum CompactionTrigger { Manual, @@ -1417,6 +1459,7 @@ pub enum CompactionTrigger { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InFlightCompaction { pub phase: CompactionPhase, pub started_at_ms: u64, @@ -1432,6 +1475,7 @@ pub struct InFlightCompaction { /// entries. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct InFlightSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, @@ -1453,6 +1497,7 @@ impl InFlightSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum InFlightBlock { Text { @@ -1476,6 +1521,7 @@ pub enum InFlightBlock { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InFlightToolCallState { #[default] @@ -1497,6 +1543,7 @@ impl InFlightToolCallState { /// their own view of the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Greeting { pub worker_name: String, pub cwd: String, @@ -1518,6 +1565,7 @@ pub struct Greeting { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerStatus { #[default] @@ -1529,6 +1577,7 @@ pub enum WorkerStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum TurnResult { Finished, @@ -1543,6 +1592,7 @@ pub enum TurnResult { /// following Turn entry, not by the marker itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum InvokeKind { /// `Method::Submit` — a user submission. @@ -1563,6 +1613,7 @@ pub enum InvokeKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum RunResult { Finished, @@ -1577,6 +1628,7 @@ pub enum RunResult { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ErrorCode { AlreadyRunning, @@ -1599,6 +1651,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))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] 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 @@ -1629,6 +1682,7 @@ fn default_recursive() -> bool { /// path as presented through the Workdir, even when it aliases another target. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum SymlinkPolicy { #[default] @@ -1644,6 +1698,7 @@ pub enum SymlinkPolicy { /// below** the stated level. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] pub enum Permission { Read, diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index c26ba6eff..5043db5ff 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -13,6 +13,13 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::Event as WorkerProtocolEvent; pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1; +/// Browser/runtime wire guardrails. These bounds are generated into the Workspace +/// Browser validator so aggregate limits remain part of the Rust-owned contract. +pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_SUBSCRIPTION_STRING_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_SUBSCRIPTION_COLLECTION_ITEMS: usize = 100_000; +pub const MAX_SUBSCRIPTION_VALUE_DEPTH: usize = 64; +pub const MAX_SUBSCRIPTION_VALUE_NODES: usize = 250_000; pub const MAX_CORRELATION_ID_BYTES: usize = 128; pub const MAX_RESOURCE_ID_BYTES: usize = 256; pub const MAX_WORKER_IDS_PER_SELECTOR: usize = 256; @@ -104,6 +111,7 @@ macro_rules! bounded_identifier { ($name:ident, $field:literal, $max:expr) => { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] + #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct $name(String); impl $name { @@ -203,6 +211,7 @@ fn validate_rejection_message(message: &str) -> Result<(), SubscriptionValidatio /// semantics is lexical, so equivalent selectors aggregate to one upstream key. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerIds(Vec); impl SubscriptionWorkerIds { @@ -257,6 +266,7 @@ impl<'de> Deserialize<'de> for SubscriptionWorkerIds { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "topic", rename_all = "snake_case")] pub enum EventSubscriptionSelector { RuntimeWorkers, @@ -308,6 +318,7 @@ impl EventSubscriptionSelector { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionFrame { pub protocol_version: u16, #[serde(flatten)] @@ -336,6 +347,7 @@ impl SubscriptionFrame { /// remain typed within each lane. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "frame", content = "message", rename_all = "snake_case")] pub enum SubscriptionFramePayload { Request(SubscriptionRequest), @@ -357,6 +369,7 @@ impl SubscriptionFramePayload { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerProtocolMethod { pub subscription_id: SubscriptionId, pub method: crate::Method, @@ -370,6 +383,7 @@ impl SubscriptionWorkerProtocolMethod { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum SubscriptionRequest { SubscribeEvents { @@ -405,6 +419,7 @@ impl SubscriptionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "result", content = "payload", rename_all = "snake_case")] pub enum SubscriptionResponse { Subscribed { @@ -467,6 +482,7 @@ impl SubscriptionResponse { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionRejectionCode { InvalidRequest, @@ -480,6 +496,7 @@ pub enum SubscriptionRejectionCode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionTerminationCode { Lagged, @@ -490,6 +507,7 @@ pub enum SubscriptionTerminationCode { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum SubscriptionEvent { Event { @@ -551,6 +569,7 @@ impl SubscriptionEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionWorkerState { Idle, @@ -564,8 +583,8 @@ pub enum SubscriptionWorkerState { /// `Unavailable` deliberately does not imply `Stopped`: it means the last observed /// lifecycle state is retained while the Runtime observation source is unavailable. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SubscriptionWorkerAvailability { #[default] @@ -575,6 +594,7 @@ pub enum SubscriptionWorkerAvailability { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkerWorkdirAttachment { /// Stable Worker-local routing alias. pub alias: String, @@ -582,6 +602,7 @@ pub struct SubscriptionWorkerWorkdirAttachment { /// omit this field and require `repository_key` from the Server projection. #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] pub repository_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_key: Option, @@ -610,6 +631,7 @@ impl SubscriptionWorkerWorkdirAttachment { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorker { pub worker_id: SubscriptionWorkerId, /// Set by the Workspace Server when projecting a Runtime-owned Worker to clients. @@ -674,12 +696,14 @@ impl SubscriptionWorker { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SubscriptionWorkdir { pub working_directory_id: SubscriptionWorkdirId, /// Runtime-internal Repository id. Workspace-facing TypeScript contracts /// omit this field and require `repository_key` from the Server projection. #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] pub repository_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_key: Option, @@ -709,6 +733,7 @@ impl SubscriptionWorkdir { /// enter this DTO; Workspace Server must resolve the required Repository key. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkspaceSubscriptionWorkdir { pub working_directory_id: SubscriptionWorkdirId, pub repository_key: String, @@ -726,6 +751,7 @@ impl WorkspaceSubscriptionWorkdir { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "topic", content = "data", rename_all = "snake_case")] pub enum SubscriptionSnapshot { Workers { @@ -789,6 +815,7 @@ impl SubscriptionSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum SubscriptionEventPayload { WorkerUpserted { diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 8ceb9ccd7..9318c3450 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use serde_json::{Map, Value}; use ts_rs::{Config, TS}; use crate::{ @@ -17,9 +18,13 @@ use crate::{ WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, subscription::{ - EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, - SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, - SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, + EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, + MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, + MAX_SUBSCRIPTION_FRAME_JSON_BYTES, MAX_SUBSCRIPTION_STRING_BYTES, + MAX_SUBSCRIPTION_VALUE_DEPTH, MAX_SUBSCRIPTION_VALUE_NODES, MAX_WORKER_IDS_PER_SELECTOR, + SUBSCRIPTION_PROTOCOL_VERSION, SubscriptionEvent, SubscriptionEventPayload, + SubscriptionFrame, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, + SubscriptionRequest, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod, SubscriptionWorkerState, @@ -28,12 +33,19 @@ use crate::{ }; const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts"; +const GENERATED_VALIDATOR_RELATIVE_PATH: &str = + "../../web/workspace/src/lib/generated/protocol-validator.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) } +/// Repository-relative destination for the generated Workspace Browser validator. +pub fn generated_runtime_validator_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_VALIDATOR_RELATIVE_PATH) +} + /// Render Workspace web TypeScript bindings for the Worker wire protocol DTOs. /// /// Rust DTOs in this crate remain the source of truth; this function is used by @@ -134,6 +146,293 @@ pub fn generated_protocol_types() -> String { normalize_typescript(output) } +/// Render the strict runtime validator paired with the generated declarations. +/// +/// `schemars` projects the reachable Serde shape from [`SubscriptionFrame`]. The +/// normalization closes every object and expands flattened enum payloads before +/// embedding that schema into the dependency-free Browser validator template. +pub fn generated_protocol_validator() -> String { + let mut schema = serde_json::to_value(schemars::schema_for!(SubscriptionFrame)) + .expect("SubscriptionFrame schema must serialize"); + close_object_shapes(&mut schema); + let schema = serde_json::to_string(&schema).expect("SubscriptionFrame schema must serialize"); + let fixtures = serde_json::to_string(&rust_serialized_subscription_frame_fixtures()) + .expect("SubscriptionFrame fixtures must serialize"); + include_str!("typescript_validator_template.ts") + .replace( + "__GENERATED_PREAMBLE__", + "// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n// Do not edit by hand. Rust DTO and validation authority lives in `crates/protocol`.", + ) + .replace( + "__PROTOCOL_VERSION__", + &SUBSCRIPTION_PROTOCOL_VERSION.to_string(), + ) + .replace( + "__MAX_FRAME_BYTES__", + &MAX_SUBSCRIPTION_FRAME_JSON_BYTES.to_string(), + ) + .replace( + "__MAX_STRING_BYTES__", + &MAX_SUBSCRIPTION_STRING_BYTES.to_string(), + ) + .replace( + "__MAX_COLLECTION_ITEMS__", + &MAX_SUBSCRIPTION_COLLECTION_ITEMS.to_string(), + ) + .replace( + "__MAX_VALUE_DEPTH__", + &MAX_SUBSCRIPTION_VALUE_DEPTH.to_string(), + ) + .replace( + "__MAX_VALUE_NODES__", + &MAX_SUBSCRIPTION_VALUE_NODES.to_string(), + ) + .replace( + "__MAX_CORRELATION_ID_BYTES__", + &MAX_CORRELATION_ID_BYTES.to_string(), + ) + .replace( + "__MAX_RESOURCE_ID_BYTES__", + &MAX_RESOURCE_ID_BYTES.to_string(), + ) + .replace( + "__MAX_WORKER_IDS_PER_SELECTOR__", + &MAX_WORKER_IDS_PER_SELECTOR.to_string(), + ) + .replace( + "__MAX_REJECTION_MESSAGE_BYTES__", + &MAX_REJECTION_MESSAGE_BYTES.to_string(), + ) + .replace("__SUBSCRIPTION_FRAME_SCHEMA__", &schema) + .replace("__RUST_SERIALIZED_FIXTURES__", &fixtures) +} + +fn rust_serialized_subscription_frame_fixtures() -> Vec { + let request_id = + SubscriptionRequestId::new("fixture-request").expect("fixture request id must be valid"); + let subscription_id = + SubscriptionId::new("fixture-subscription").expect("fixture subscription id must be valid"); + let worker_id = + SubscriptionWorkerId::new("fixture-worker").expect("fixture worker id must be valid"); + let worker = SubscriptionWorker { + worker_id: worker_id.clone(), + runtime_id: Some("fixture-runtime".to_string()), + resource_key: Some("fixture-worker-resource".to_string()), + availability: SubscriptionWorkerAvailability::default(), + subject_revision: 1, + worker_state: None, + state: SubscriptionWorkerState::Idle, + has_running_internal_workers: false, + workspace_id: Some("fixture-workspace".to_string()), + display_name: None, + profile: None, + workdir_attachments: Vec::new(), + }; + let subscribed = SubscriptionFrame::new(SubscriptionFramePayload::Response( + SubscriptionResponse::Subscribed { + request_id, + subscription_id: subscription_id.clone(), + selector: EventSubscriptionSelector::WorkspaceWorkers, + snapshot_revision: 1, + snapshot: SubscriptionSnapshot::Workers { + workers: vec![worker], + }, + }, + )); + let pending = + SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { + subscription_id, + subject_revision: 2, + payload: SubscriptionEventPayload::WorkerProtocol { + worker_id, + event: Event::PendingSubmissionsChanged { + pending: PendingSubmissionsSnapshot::default(), + }, + }, + })); + for fixture in [&subscribed, &pending] { + fixture + .validate() + .expect("Rust-serialized Browser compatibility fixture must validate"); + } + vec![subscribed, pending] +} + +fn close_object_shapes(schema: &mut Value) { + let Value::Object(object) = schema else { + if let Value::Array(values) = schema { + for value in values { + close_object_shapes(value); + } + } + return; + }; + + expand_flattened_object(object); + for value in object.values_mut() { + close_object_shapes(value); + } + normalize_null_default(object); + normalize_integer_format(object); + require_serialized_defaults(object); + if object.get("type").and_then(Value::as_str) == Some("object") + && object.contains_key("properties") + { + object.insert("additionalProperties".to_string(), Value::Bool(false)); + } +} + +fn normalize_null_default(schema: &mut Map) { + if schema.get("default") != Some(&Value::Null) || schema.contains_key("anyOf") { + return; + } + let Some(schema_type) = schema.get_mut("type") else { + return; + }; + match schema_type { + Value::String(value) if value != "null" => { + *schema_type = Value::Array(vec![ + Value::String(value.clone()), + Value::String("null".into()), + ]); + } + Value::Array(values) if !values.iter().any(|value| value.as_str() == Some("null")) => { + values.push(Value::String("null".into())); + } + _ => {} + } +} + +fn normalize_integer_format(schema: &mut Map) { + let is_integer = match schema.get("type") { + Some(Value::String(value)) => value == "integer", + Some(Value::Array(values)) => values.iter().any(|value| value.as_str() == Some("integer")), + _ => false, + }; + if !is_integer { + return; + } + let Some(format) = schema.get("format").and_then(Value::as_str) else { + return; + }; + let (minimum, maximum) = match format { + "uint16" => (0.0, u16::MAX as f64), + "uint32" => (0.0, u32::MAX as f64), + "uint" | "uint64" => (0.0, 9_007_199_254_740_991.0), + "int32" => (i32::MIN as f64, i32::MAX as f64), + "int64" => (-9_007_199_254_740_991.0, 9_007_199_254_740_991.0), + other => panic!("unsupported integer format in SubscriptionFrame schema: {other}"), + }; + tighten_numeric_bound(schema, "minimum", minimum, f64::max); + tighten_numeric_bound(schema, "maximum", maximum, f64::min); +} + +fn tighten_numeric_bound( + schema: &mut Map, + keyword: &str, + contract_bound: f64, + tighten: fn(f64, f64) -> f64, +) { + let bound = schema + .get(keyword) + .and_then(Value::as_f64) + .map_or(contract_bound, |existing| tighten(existing, contract_bound)); + schema.insert( + keyword.to_string(), + Value::Number(serde_json::Number::from_f64(bound).expect("finite JSON Schema bound")), + ); +} + +fn require_serialized_defaults(schema: &mut Map) { + if schema.get("type").and_then(Value::as_str) != Some("object") { + return; + } + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return; + }; + let defaulted = properties + .iter() + .filter(|(_, property)| { + property + .as_object() + .is_some_and(|value| value.contains_key("default")) + }) + .map(|(key, _)| Value::String(key.clone())) + .collect::>(); + if defaulted.is_empty() { + return; + } + let required = schema + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("object required must be an array"); + for key in defaulted { + if !required.contains(&key) { + required.push(key); + } + } +} + +/// Serde flatten is represented by schemars as object properties beside `oneOf`. +/// Move those common properties into each object alternative so standard JSON +/// Schema `additionalProperties: false` has the intended exact-shape semantics. +fn expand_flattened_object(object: &mut Map) { + if object.get("type").and_then(Value::as_str) != Some("object") + || !object.contains_key("properties") + { + return; + } + let Some(alternatives) = object.get("oneOf").and_then(Value::as_array) else { + return; + }; + if !alternatives.iter().all(|alternative| { + alternative + .as_object() + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + == Some("object") + }) { + return; + } + + let base_properties = object + .remove("properties") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + let base_required = object + .remove("required") + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + object.remove("type"); + let alternatives = object + .get_mut("oneOf") + .and_then(Value::as_array_mut) + .expect("oneOf was checked above"); + for alternative in alternatives { + let alternative = alternative + .as_object_mut() + .expect("oneOf object alternative was checked above"); + let properties = alternative + .entry("properties") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .expect("object alternative properties must be an object"); + for (key, value) in &base_properties { + assert!( + properties.insert(key.clone(), value.clone()).is_none(), + "flattened schema property {key:?} collides with an enum payload" + ); + } + let required = alternative + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("object alternative required must be an array"); + required.extend(base_required.iter().cloned()); + } +} + fn normalize_typescript(output: String) -> String { let mut lines = output.lines().map(str::trim_end).collect::>(); while lines.last() == Some(&"") { @@ -169,6 +468,41 @@ mod tests { assert!(generated.contains("repository_key"), "{generated}"); } + #[test] + fn generated_schema_matches_serialized_defaults_and_rust_integer_widths() { + let mut schema = serde_json::to_value(schemars::schema_for!(SubscriptionFrame)).unwrap(); + close_object_shapes(&mut schema); + + let worker_required = schema + .pointer("/$defs/SubscriptionWorker/required") + .and_then(Value::as_array) + .unwrap(); + assert!(worker_required.contains(&Value::String("availability".into()))); + assert!(worker_required.contains(&Value::String("has_running_internal_workers".into()))); + + let pending_required = schema + .pointer("/$defs/PendingSubmissionsSnapshot/required") + .and_then(Value::as_array) + .unwrap(); + for field in ["notification_count", "head_id", "submissions"] { + assert!(pending_required.contains(&Value::String(field.into()))); + } + assert_eq!( + schema.pointer("/$defs/PendingSubmissionsSnapshot/properties/head_id/type"), + Some(&serde_json::json!(["string", "null"])) + ); + assert!( + generated_protocol_validator().contains("\"format\":\"uint32\",\"maximum\":4294967295") + ); + } + + #[test] + #[should_panic(expected = "unsupported integer format")] + fn generated_schema_rejects_unknown_integer_formats() { + let mut unsupported = serde_json::json!({"type": "integer", "format": "uint128"}); + close_object_shapes(&mut unsupported); + } + #[test] fn generated_protocol_types_are_current() { let expected = generated_protocol_types(); @@ -180,4 +514,18 @@ mod tests { "generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`" ); } + + #[test] + fn generated_protocol_validator_is_current_and_strict() { + let expected = generated_protocol_validator(); + assert!(expected.contains("\"additionalProperties\":false")); + assert!(expected.contains("MAX_SUBSCRIPTION_FRAME_JSON_BYTES = 16777216")); + let path = generated_runtime_validator_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 validator is stale; run `cargo run -p protocol --example generate_typescript --features typescript`" + ); + } } diff --git a/crates/protocol/src/typescript_validator_template.ts b/crates/protocol/src/typescript_validator_template.ts new file mode 100644 index 000000000..0bf0ec606 --- /dev/null +++ b/crates/protocol/src/typescript_validator_template.ts @@ -0,0 +1,434 @@ +__GENERATED_PREAMBLE__ + +import type { + EventSubscriptionSelector, + SubscriptionEventPayload, + SubscriptionFrame, + SubscriptionSnapshot, + SubscriptionWorker, + WorkspaceSubscriptionWorkdir, +} from './protocol'; + +export const SUBSCRIPTION_PROTOCOL_VERSION = __PROTOCOL_VERSION__; +export const MAX_SUBSCRIPTION_FRAME_JSON_BYTES = __MAX_FRAME_BYTES__; +export const MAX_SUBSCRIPTION_STRING_BYTES = __MAX_STRING_BYTES__; +export const MAX_SUBSCRIPTION_COLLECTION_ITEMS = __MAX_COLLECTION_ITEMS__; +export const MAX_SUBSCRIPTION_VALUE_DEPTH = __MAX_VALUE_DEPTH__; +export const MAX_SUBSCRIPTION_VALUE_NODES = __MAX_VALUE_NODES__; + +const MAX_CORRELATION_ID_BYTES = __MAX_CORRELATION_ID_BYTES__; +const MAX_RESOURCE_ID_BYTES = __MAX_RESOURCE_ID_BYTES__; +const MAX_WORKER_IDS_PER_SELECTOR = __MAX_WORKER_IDS_PER_SELECTOR__; +const MAX_REJECTION_MESSAGE_BYTES = __MAX_REJECTION_MESSAGE_BYTES__; +const subscriptionFrameSchema: unknown = __SUBSCRIPTION_FRAME_SCHEMA__; +export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = + __RUST_SERIALIZED_FIXTURES__; +const utf8 = new TextEncoder(); + +type JsonObject = Record; +type RejectionReason = + | 'invalid_transport' + | 'frame_too_large' + | 'invalid_json' + | 'aggregate_limit' + | 'invalid_structure' + | 'invalid_semantics'; + +export type SubscriptionFrameDecodeResult = + | { ok: true; value: SubscriptionFrame } + | { ok: false; reason: RejectionReason }; + +/** + * Decode an untrusted Workspace WebSocket message without reflecting its content. + * A successful result has passed aggregate limits, the exact generated JSON schema, + * and the Rust protocol's Browser-relevant semantic checks. + */ +export function decodeSubscriptionFrame(input: unknown): SubscriptionFrameDecodeResult { + if (typeof input !== 'string') return { ok: false, reason: 'invalid_transport' }; + if (utf8.encode(input).byteLength > MAX_SUBSCRIPTION_FRAME_JSON_BYTES) { + return { ok: false, reason: 'frame_too_large' }; + } + + let candidate: unknown; + try { + candidate = JSON.parse(input) as unknown; + } catch { + return { ok: false, reason: 'invalid_json' }; + } + if (!withinAggregateLimits(candidate)) return { ok: false, reason: 'aggregate_limit' }; + if (!validateSchema(candidate, subscriptionFrameSchema)) { + return { ok: false, reason: 'invalid_structure' }; + } + + // The assertion follows generated structural validation; it is not a parse-time cast. + const frame = candidate as SubscriptionFrame; + if (!validateInboundFrameSemantics(frame)) { + return { ok: false, reason: 'invalid_semantics' }; + } + return { ok: true, value: frame }; +} + +/** Validate stateful selector/payload consistency after the multiplexer resolves routing. */ +export function subscriptionFrameMatchesSelector( + frame: SubscriptionFrame, + selector: EventSubscriptionSelector, +): boolean { + if (frame.frame === 'response' && frame.message.result === 'subscribed') { + return selectorsEqual(frame.message.payload.selector, selector); + } + if (frame.frame === 'event' && frame.message.event === 'event') { + return eventPayloadMatchesSelector(frame.message.data.payload, selector); + } + return true; +} + +function withinAggregateLimits(root: unknown): boolean { + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + nodes += 1; + if (nodes > MAX_SUBSCRIPTION_VALUE_NODES || depth > MAX_SUBSCRIPTION_VALUE_DEPTH) return false; + if (typeof value === 'string') { + return utf8.encode(value).byteLength <= MAX_SUBSCRIPTION_STRING_BYTES; + } + if (typeof value === 'number') { + return Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value)); + } + if (value === null || typeof value === 'boolean') return true; + if (Array.isArray(value)) { + return ( + value.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + value.every((entry) => visit(entry, depth + 1)) + ); + } + if (!isObject(value)) return false; + const entries = Object.entries(value); + return ( + entries.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + entries.every( + ([key, entry]) => visit(key, depth + 1) && visit(entry, depth + 1), + ) + ); + }; + return visit(root, 0); +} + +function validateSchema(value: unknown, rawSchema: unknown): boolean { + if (rawSchema === true) return true; + if (rawSchema === false || !isObject(rawSchema)) return false; + const schema = rawSchema; + + if (typeof schema.$ref === 'string') { + const target = resolveLocalReference(schema.$ref); + if (target === undefined || !validateSchema(value, target)) return false; + } + if ('const' in schema && !jsonEqual(value, schema.const)) return false; + if (Array.isArray(schema.enum) && !schema.enum.some((entry) => jsonEqual(value, entry))) { + return false; + } + if (Array.isArray(schema.oneOf)) { + let matches = 0; + for (const alternative of schema.oneOf) { + if (validateSchema(value, alternative)) matches += 1; + } + if (matches !== 1) return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((entry) => validateSchema(value, entry))) { + return false; + } + + if (schema.type !== undefined && !matchesType(value, schema.type)) return false; + if (typeof value === 'number') { + if (schema.type === 'integer' && !Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + const length = [...value].length; + if (typeof schema.minLength === 'number' && length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && length > schema.maxLength) return false; + } + if (Array.isArray(value)) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + if (schema.items !== undefined && !value.every((entry) => validateSchema(entry, schema.items))) { + return false; + } + } + if (isObject(value) && schema.properties !== undefined) { + if (!isObject(schema.properties)) return false; + const properties = schema.properties; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key)) return false; + } + } + for (const [key, entry] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + if (!validateSchema(entry, properties[key])) return false; + } else if (schema.additionalProperties === false) { + return false; + } else if (isObject(schema.additionalProperties) || typeof schema.additionalProperties === 'boolean') { + if (!validateSchema(entry, schema.additionalProperties)) return false; + } + } + } + return true; +} + +function resolveLocalReference(reference: string): unknown { + if (!reference.startsWith('#/')) return undefined; + let current: unknown = subscriptionFrameSchema; + for (const encoded of reference.slice(2).split('/')) { + if (!isObject(current)) return undefined; + const key = encoded.replaceAll('~1', '/').replaceAll('~0', '~'); + current = current[key]; + } + return current; +} + +function matchesType(value: unknown, rawType: unknown): boolean { + if (Array.isArray(rawType)) return rawType.some((entry) => matchesType(value, entry)); + switch (rawType) { + case 'null': + return value === null; + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isSafeInteger(value); + case 'array': + return Array.isArray(value); + case 'object': + return isObject(value); + default: + return false; + } +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((entry, index) => jsonEqual(entry, right[index])); + } + if (isObject(left) && isObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => Object.prototype.hasOwnProperty.call(right, key) && jsonEqual(left[key], right[key]), + ) + ); + } + return false; +} + +function validateInboundFrameSemantics(frame: SubscriptionFrame): boolean { + if (frame.protocol_version !== SUBSCRIPTION_PROTOCOL_VERSION) return false; + if (frame.frame === 'request' || frame.frame === 'worker_protocol') return false; + if (frame.frame === 'response') { + const message = frame.message; + if (!validateIdentifier(message.payload.request_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.result === 'subscribed') { + return ( + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES) && + validateSelector(message.payload.selector) && + snapshotMatchesSelector(message.payload.snapshot, message.payload.selector) + ); + } + if (message.result === 'unsubscribed') { + return validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES); + } + return ( + (message.payload.subscription_id == null || + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES)) && + validateBoundedMessage(message.payload.message) + ); + } + + const message = frame.message; + if (!validateIdentifier(message.data.subscription_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.event === 'subscription_closed') { + return validateBoundedMessage(message.data.message); + } + return validateEventPayload(message.data.payload); +} + +function validateSelector(selector: EventSubscriptionSelector): boolean { + if (selector.topic === 'worker_lifecycle') { + if ( + selector.worker_ids.length === 0 || + selector.worker_ids.length > MAX_WORKER_IDS_PER_SELECTOR + ) { + return false; + } + const ids = new Set(); + for (const workerId of selector.worker_ids) { + if (!validateIdentifier(workerId, MAX_RESOURCE_ID_BYTES) || ids.has(workerId)) return false; + ids.add(workerId); + } + return true; + } + if (selector.topic === 'worker_protocol') { + return ( + validateIdentifier(selector.worker_id, MAX_RESOURCE_ID_BYTES) && + (selector.runtime_id == null || + validateIdentifier(selector.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + } + return true; +} + +function snapshotMatchesSelector( + snapshot: SubscriptionSnapshot, + selector: EventSubscriptionSelector, +): boolean { + if (!validateSelector(selector)) return false; + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return snapshot.topic === 'workers' && validateWorkers(snapshot.data.workers); + } + if (selector.topic === 'worker_lifecycle') { + const selected = new Set(selector.worker_ids); + return ( + snapshot.topic === 'workers' && + validateWorkers(snapshot.data.workers) && + snapshot.data.workers.every((worker) => selected.has(worker.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return snapshot.topic === 'worker_protocol' && snapshot.data.worker_id === selector.worker_id; + } + return ( + snapshot.topic === 'workspace_workdirs' && + snapshot.data.workdirs.every(validateWorkspaceWorkdir) + ); +} + +function validateEventPayload(payload: SubscriptionEventPayload): boolean { + switch (payload.event) { + case 'worker_upserted': + return validateWorker(payload.data.worker); + case 'worker_removed': + return ( + validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES) && + (payload.data.runtime_id == null || + validateIdentifier(payload.data.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + case 'worker_protocol': + return validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES); + case 'workdir_upserted': + return validateWorkspaceWorkdir(payload.data.workdir); + case 'workdir_removed': + return validateIdentifier(payload.data.working_directory_id, MAX_RESOURCE_ID_BYTES); + } +} + +function eventPayloadMatchesSelector( + payload: SubscriptionEventPayload, + selector: EventSubscriptionSelector, +): boolean { + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return payload.event === 'worker_upserted' || payload.event === 'worker_removed'; + } + if (selector.topic === 'worker_lifecycle') { + return ( + (payload.event === 'worker_upserted' && selector.worker_ids.includes(payload.data.worker.worker_id)) || + (payload.event === 'worker_removed' && selector.worker_ids.includes(payload.data.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return payload.event === 'worker_protocol' && payload.data.worker_id === selector.worker_id; + } + return payload.event === 'workdir_upserted' || payload.event === 'workdir_removed'; +} + +function selectorsEqual( + left: EventSubscriptionSelector, + right: EventSubscriptionSelector, +): boolean { + if (left.topic !== right.topic) return false; + if (left.topic === 'worker_lifecycle' && right.topic === 'worker_lifecycle') { + const rightIds = new Set(right.worker_ids); + return left.worker_ids.length === rightIds.size && left.worker_ids.every((workerId) => rightIds.has(workerId)); + } + if (left.topic === 'worker_protocol' && right.topic === 'worker_protocol') { + return ( + left.worker_id === right.worker_id && + (left.runtime_id ?? null) === (right.runtime_id ?? null) + ); + } + return true; +} + +function validateWorkers(workers: SubscriptionWorker[]): boolean { + const identities = new Set(); + for (const worker of workers) { + const identity = `${worker.runtime_id ?? ''}\u0000${worker.worker_id}`; + if (!validateWorker(worker) || identities.has(identity)) return false; + identities.add(identity); + } + return true; +} + +function validateWorker(worker: SubscriptionWorker): boolean { + if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false; + if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) { + return false; + } + if (worker.resource_key != null && !validateIdentifier(worker.resource_key, MAX_RESOURCE_ID_BYTES)) { + return false; + } + const aliases = new Set(); + const workdirIds = new Set(); + for (const attachment of worker.workdir_attachments ?? []) { + if ( + !validateIdentifier(attachment.alias, MAX_RESOURCE_ID_BYTES) || + !validateIdentifier(attachment.working_directory_id, MAX_RESOURCE_ID_BYTES) || + attachment.repository_key == null || + !validateRepositoryKey(attachment.repository_key) || + aliases.has(attachment.alias) || + workdirIds.has(attachment.working_directory_id) + ) { + return false; + } + aliases.add(attachment.alias); + workdirIds.add(attachment.working_directory_id); + } + return true; +} + +function validateWorkspaceWorkdir(workdir: WorkspaceSubscriptionWorkdir): boolean { + return ( + validateIdentifier(workdir.working_directory_id, MAX_RESOURCE_ID_BYTES) && + validateRepositoryKey(workdir.repository_key) && + validateIdentifier(workdir.state, MAX_RESOURCE_ID_BYTES) + ); +} + +function validateIdentifier(value: string, maxBytes: number): boolean { + return ( + value.length > 0 && + utf8.encode(value).byteLength <= maxBytes && + value.trim() === value && + !/\p{Cc}/u.test(value) + ); +} + +function validateRepositoryKey(value: string): boolean { + return ( + utf8.encode(value).byteLength <= 64 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) + ); +} + +function validateBoundedMessage(value: string): boolean { + const bytes = utf8.encode(value).byteLength; + return bytes > 0 && bytes <= MAX_REJECTION_MESSAGE_BYTES; +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d84dad089..568246be3 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1016,12 +1016,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { worker_ref: &WorkerRef, ) -> Result { - let Some(root) = self.worker_aggregate_root.as_ref() else { - return Err(session_store::RetainedSnapshotReadError::RetentionMissing); - }; + let aggregate_dir = self + .worker_aggregate_dir(worker_ref) + .map_err(|_| session_store::RetainedSnapshotReadError::RetentionMissing)?; let worker_name = Self::runtime_worker_name_for_ref(worker_ref); session_store::read_retained_session_snapshot( - &root.join(&worker_name), + &aggregate_dir, &worker_name, session_store::DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, ) @@ -3135,11 +3135,15 @@ mod tests { use crate::working_directory::RuntimeGitMaterializer; use agen::Engine; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; - use agen::llm_client::{ClientError, LlmClient, Request}; + use agen::llm_client::{ClientError, LlmClient, Request, RequestConfig}; use async_trait::async_trait; use futures::{Stream, StreamExt}; use manifest::{Scope, WorkerManifest}; - use session_store::{LogEntry, WorkerMetadataStore}; + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store, WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, + }; #[test] fn production_source_has_no_repository_derived_runtime_store() { @@ -3155,6 +3159,217 @@ mod tests { } } + fn persisted_files(root: &Path) -> BTreeMap> { + fn collect(root: &Path, current: &Path, files: &mut BTreeMap>) { + if !current.exists() { + return; + } + for entry in fs::read_dir(current).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(root, &path, files); + } else { + files.insert( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + ); + } + } + } + + let mut files = BTreeMap::new(); + collect(root, root, &mut files); + files + } + + fn write_retained_profile_worker( + runtime_store_dir: &Path, + worker_ref: &WorkerRef, + ) -> (session_store::SessionId, session_store::SegmentId, String) { + let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(worker_ref); + let aggregate_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let aggregate = WorkerAggregateStore::new(&aggregate_dir, &worker_name).unwrap(); + let session_id = session_store::new_session_id(); + let segment_id = session_store::new_segment_id(); + aggregate + .write(&WorkerMetadata::new( + &worker_name, + Some(WorkerActiveSegmentRef::active_segment( + session_id, segment_id, + )), + )) + .unwrap(); + let entry_id = LoggedSessionHistoryEntryId::new(); + let expected_entry_id = entry_id.0.clone(); + let log = [LogEntry::AnnotatedSegmentStart { + ts: 17, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history: vec![LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::Assistant, + content: vec![LoggedContentPart::Text { + text: "retained response".to_string(), + }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id, + origin: LoggedSessionHistoryOrigin::LegacyUnknown, + derivation: None, + }, + }], + forked_from: None, + compacted_from: None, + }]; + WorkerSessionStore::new(aggregate_dir.join("session")) + .unwrap() + .create_segment(session_id, segment_id, &log) + .unwrap(); + (session_id, segment_id, expected_entry_id) + } + + #[test] + fn profile_factory_reads_retained_session_from_canonical_worker_aggregate() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let (session_id, segment_id, entry_id) = + write_retained_profile_worker(&runtime_store_dir, &worker_ref); + let canonical_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let legacy_prefixed_dir = runtime_store_dir.join("workers").join( + ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref), + ); + assert!(!legacy_prefixed_dir.exists()); + let files_before = persisted_files(&runtime_store_dir); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let retained = backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }); + + let runtime_api::WorkerSessionAvailability::RetainedSnapshot { identity, snapshot } = + retained + else { + panic!("canonical aggregate must produce a retained snapshot"); + }; + assert_eq!(identity.session_id, session_id.to_string()); + assert_eq!(identity.segment_id, segment_id.to_string()); + assert_eq!(identity.entry_count, 1); + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].entry_id, entry_id); + assert_eq!( + snapshot.entries[0].data, + protocol::SessionSnapshotEntryData::Message { + role: protocol::SessionMessageRole::Assistant, + content: vec![protocol::SessionContentPart::Text { + text: "retained response".to_string(), + }], + } + ); + assert!(canonical_dir.is_dir()); + assert!(!legacy_prefixed_dir.exists()); + assert_eq!(persisted_files(&runtime_store_dir), files_before); + assert!(backend.workers.lock().unwrap().is_empty()); + assert!( + backend + .factory + .observation_hub + .workers + .lock() + .unwrap() + .is_empty() + ); + } + + #[test] + fn profile_factory_retained_read_keeps_missing_aggregate_typed_and_absent() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let unconfigured_backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("unconfigured-profiles")), + ) + .unwrap(); + assert!(matches!( + unconfigured_backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }), + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::RetentionMissing, + .. + } + )); + assert!(!root.path().join("unconfigured-profiles").exists()); + assert!(unconfigured_backend.workers.lock().unwrap().is_empty()); + + let canonical_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let unavailable = backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }); + + assert!(matches!( + unavailable, + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::RetentionMissing, + .. + } + )); + assert!(!canonical_dir.exists()); + assert!(!runtime_store_dir.exists()); + assert!(backend.workers.lock().unwrap().is_empty()); + } + + #[test] + fn profile_factory_retained_read_validates_metadata_worker_identity() { + let root = tempfile::tempdir().unwrap(); + let runtime_store_dir = root.path().join("runtime"); + let worker_ref = WorkerRef::new(WorkerId::now_v7()); + let aggregate_dir = runtime_store_dir + .join("workers") + .join(worker_ref.worker_id.to_string()); + fs::create_dir_all(&aggregate_dir).unwrap(); + fs::write( + aggregate_dir.join("metadata.json"), + serde_json::to_vec(&WorkerMetadata::new("different-worker", None)).unwrap(), + ) + .unwrap(); + let files_before = persisted_files(&runtime_store_dir); + let backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(root.path().join("profiles")) + .with_runtime_store_dir(&runtime_store_dir), + ) + .unwrap(); + + let unavailable = backend.worker_session(WorkerSessionObservationRequest { worker_ref }); + + assert!(matches!( + unavailable, + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::CorruptLog, + ref message, + } if message == "retained session log is corrupt" + )); + assert_eq!(persisted_files(&runtime_store_dir), files_before); + assert!(backend.workers.lock().unwrap().is_empty()); + } + #[test] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { let root = tempfile::tempdir().unwrap(); diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a7e59c3c8..05284f684 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", diff --git a/web/workspace/src/lib/generated/README.md b/web/workspace/src/lib/generated/README.md index 18d58e70d..65613dbe7 100644 --- a/web/workspace/src/lib/generated/README.md +++ b/web/workspace/src/lib/generated/README.md @@ -15,6 +15,21 @@ cargo run -q -p server-api --example generate_repository_openapi_types -- --chec The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, semantic `$ref` siblings, unimplemented ordinary schema keywords, unsupported enum literals/formats, and integer ranges that cannot be represented safely by JavaScript numbers. Its one explicit format mapping is canonical `uint64` to a nonnegative JavaScript safe `number`; the runtime parser enforces the same range. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types. +## Workspace subscription / Worker protocol + +`protocol.ts` and `protocol-validator.ts` are generated together from the Serde/`schemars` closure of `protocol::subscription::SubscriptionFrame`. Rust DTOs and validation constants in `crates/protocol` remain the sole wire authority. The generator normalizes Serde defaults to the actual serialized required/nullable shape, tightens every supported Rust integer format to its JavaScript-safe wire range, and fails on an unknown integer format. The validator closes every generated object shape, rejects unknown tagged variants and unsafe integers, applies Rust-owned aggregate bounds to object keys as well as values, and mirrors the Browser-relevant `SubscriptionFrame::validate()` selector/payload checks. Rust-serialized compatibility fixtures are embedded for Browser regression tests. The Workspace multiplexer additionally checks live event payloads against its locally retained selector. + +Inbound Browser frames only are validated: Browser-to-Server methods remain compile-time typed and are decoded plus semantically validated by the Rust server. Invalid inbound frames close the shared socket with a fixed, non-reflective diagnostic; the existing reconnect path clears routing state and requests fresh snapshots. A valid `subscription_closed` frame continues to isolate and resubscribe only that subscription. + +Regenerate or verify both artifacts from the repository root: + +```sh +cargo run -q -p protocol --example generate_typescript --features typescript +cargo run -q -p protocol --example generate_typescript --features typescript -- --check +``` + +The `protocol` crate tests also compare both checked-in files byte-for-byte with fresh generator output. + ## Other generated contracts The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it. diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts new file mode 100644 index 000000000..4f91c778e --- /dev/null +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -0,0 +1,435 @@ +// @generated by `cargo run -p protocol --example generate_typescript --features typescript` +// Do not edit by hand. Rust DTO and validation authority lives in `crates/protocol`. + +import type { + EventSubscriptionSelector, + SubscriptionEventPayload, + SubscriptionFrame, + SubscriptionSnapshot, + SubscriptionWorker, + WorkspaceSubscriptionWorkdir, +} from './protocol'; + +export const SUBSCRIPTION_PROTOCOL_VERSION = 1; +export const MAX_SUBSCRIPTION_FRAME_JSON_BYTES = 16777216; +export const MAX_SUBSCRIPTION_STRING_BYTES = 4194304; +export const MAX_SUBSCRIPTION_COLLECTION_ITEMS = 100000; +export const MAX_SUBSCRIPTION_VALUE_DEPTH = 64; +export const MAX_SUBSCRIPTION_VALUE_NODES = 250000; + +const MAX_CORRELATION_ID_BYTES = 128; +const MAX_RESOURCE_ID_BYTES = 256; +const MAX_WORKER_IDS_PER_SELECTOR = 256; +const MAX_REJECTION_MESSAGE_BYTES = 1024; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. New submissions are accepted only while Idle,\nand a `Started` receipt follows the pending activation checkpoint; the\ncorresponding [`Event::UserMessage`] marks the later history commit.\n`Queued` remains readable for receipts persisted by older runtimes.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation while the Worker is Idle.\nRunning or Paused Workers reject Submit; use Notify for advisory information\nthat should be incorporated into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Payload segments and\nprovenance remain in the session log and are intentionally not exposed.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Compatibility value for durable receipts created before Submit became\nIdle-only. New submissions are never accepted with this disposition.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subject_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","subject_revision","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"snapshot_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot_revision","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"subject_revision":{"description":"Producer-owned monotonic revision for this Worker subject.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest revisioned foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","subject_revision","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = + [{"protocol_version":1,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot_revision":1,"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","subject_revision":1,"state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace"}]}}}}},{"protocol_version":1,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","subject_revision":2,"payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; +const utf8 = new TextEncoder(); + +type JsonObject = Record; +type RejectionReason = + | 'invalid_transport' + | 'frame_too_large' + | 'invalid_json' + | 'aggregate_limit' + | 'invalid_structure' + | 'invalid_semantics'; + +export type SubscriptionFrameDecodeResult = + | { ok: true; value: SubscriptionFrame } + | { ok: false; reason: RejectionReason }; + +/** + * Decode an untrusted Workspace WebSocket message without reflecting its content. + * A successful result has passed aggregate limits, the exact generated JSON schema, + * and the Rust protocol's Browser-relevant semantic checks. + */ +export function decodeSubscriptionFrame(input: unknown): SubscriptionFrameDecodeResult { + if (typeof input !== 'string') return { ok: false, reason: 'invalid_transport' }; + if (utf8.encode(input).byteLength > MAX_SUBSCRIPTION_FRAME_JSON_BYTES) { + return { ok: false, reason: 'frame_too_large' }; + } + + let candidate: unknown; + try { + candidate = JSON.parse(input) as unknown; + } catch { + return { ok: false, reason: 'invalid_json' }; + } + if (!withinAggregateLimits(candidate)) return { ok: false, reason: 'aggregate_limit' }; + if (!validateSchema(candidate, subscriptionFrameSchema)) { + return { ok: false, reason: 'invalid_structure' }; + } + + // The assertion follows generated structural validation; it is not a parse-time cast. + const frame = candidate as SubscriptionFrame; + if (!validateInboundFrameSemantics(frame)) { + return { ok: false, reason: 'invalid_semantics' }; + } + return { ok: true, value: frame }; +} + +/** Validate stateful selector/payload consistency after the multiplexer resolves routing. */ +export function subscriptionFrameMatchesSelector( + frame: SubscriptionFrame, + selector: EventSubscriptionSelector, +): boolean { + if (frame.frame === 'response' && frame.message.result === 'subscribed') { + return selectorsEqual(frame.message.payload.selector, selector); + } + if (frame.frame === 'event' && frame.message.event === 'event') { + return eventPayloadMatchesSelector(frame.message.data.payload, selector); + } + return true; +} + +function withinAggregateLimits(root: unknown): boolean { + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + nodes += 1; + if (nodes > MAX_SUBSCRIPTION_VALUE_NODES || depth > MAX_SUBSCRIPTION_VALUE_DEPTH) return false; + if (typeof value === 'string') { + return utf8.encode(value).byteLength <= MAX_SUBSCRIPTION_STRING_BYTES; + } + if (typeof value === 'number') { + return Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value)); + } + if (value === null || typeof value === 'boolean') return true; + if (Array.isArray(value)) { + return ( + value.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + value.every((entry) => visit(entry, depth + 1)) + ); + } + if (!isObject(value)) return false; + const entries = Object.entries(value); + return ( + entries.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS && + entries.every( + ([key, entry]) => visit(key, depth + 1) && visit(entry, depth + 1), + ) + ); + }; + return visit(root, 0); +} + +function validateSchema(value: unknown, rawSchema: unknown): boolean { + if (rawSchema === true) return true; + if (rawSchema === false || !isObject(rawSchema)) return false; + const schema = rawSchema; + + if (typeof schema.$ref === 'string') { + const target = resolveLocalReference(schema.$ref); + if (target === undefined || !validateSchema(value, target)) return false; + } + if ('const' in schema && !jsonEqual(value, schema.const)) return false; + if (Array.isArray(schema.enum) && !schema.enum.some((entry) => jsonEqual(value, entry))) { + return false; + } + if (Array.isArray(schema.oneOf)) { + let matches = 0; + for (const alternative of schema.oneOf) { + if (validateSchema(value, alternative)) matches += 1; + } + if (matches !== 1) return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((entry) => validateSchema(value, entry))) { + return false; + } + + if (schema.type !== undefined && !matchesType(value, schema.type)) return false; + if (typeof value === 'number') { + if (schema.type === 'integer' && !Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + const length = [...value].length; + if (typeof schema.minLength === 'number' && length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && length > schema.maxLength) return false; + } + if (Array.isArray(value)) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + if (schema.items !== undefined && !value.every((entry) => validateSchema(entry, schema.items))) { + return false; + } + } + if (isObject(value) && schema.properties !== undefined) { + if (!isObject(schema.properties)) return false; + const properties = schema.properties; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key)) return false; + } + } + for (const [key, entry] of Object.entries(value)) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + if (!validateSchema(entry, properties[key])) return false; + } else if (schema.additionalProperties === false) { + return false; + } else if (isObject(schema.additionalProperties) || typeof schema.additionalProperties === 'boolean') { + if (!validateSchema(entry, schema.additionalProperties)) return false; + } + } + } + return true; +} + +function resolveLocalReference(reference: string): unknown { + if (!reference.startsWith('#/')) return undefined; + let current: unknown = subscriptionFrameSchema; + for (const encoded of reference.slice(2).split('/')) { + if (!isObject(current)) return undefined; + const key = encoded.replaceAll('~1', '/').replaceAll('~0', '~'); + current = current[key]; + } + return current; +} + +function matchesType(value: unknown, rawType: unknown): boolean { + if (Array.isArray(rawType)) return rawType.some((entry) => matchesType(value, entry)); + switch (rawType) { + case 'null': + return value === null; + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isSafeInteger(value); + case 'array': + return Array.isArray(value); + case 'object': + return isObject(value); + default: + return false; + } +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((entry, index) => jsonEqual(entry, right[index])); + } + if (isObject(left) && isObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => Object.prototype.hasOwnProperty.call(right, key) && jsonEqual(left[key], right[key]), + ) + ); + } + return false; +} + +function validateInboundFrameSemantics(frame: SubscriptionFrame): boolean { + if (frame.protocol_version !== SUBSCRIPTION_PROTOCOL_VERSION) return false; + if (frame.frame === 'request' || frame.frame === 'worker_protocol') return false; + if (frame.frame === 'response') { + const message = frame.message; + if (!validateIdentifier(message.payload.request_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.result === 'subscribed') { + return ( + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES) && + validateSelector(message.payload.selector) && + snapshotMatchesSelector(message.payload.snapshot, message.payload.selector) + ); + } + if (message.result === 'unsubscribed') { + return validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES); + } + return ( + (message.payload.subscription_id == null || + validateIdentifier(message.payload.subscription_id, MAX_CORRELATION_ID_BYTES)) && + validateBoundedMessage(message.payload.message) + ); + } + + const message = frame.message; + if (!validateIdentifier(message.data.subscription_id, MAX_CORRELATION_ID_BYTES)) return false; + if (message.event === 'subscription_closed') { + return validateBoundedMessage(message.data.message); + } + return validateEventPayload(message.data.payload); +} + +function validateSelector(selector: EventSubscriptionSelector): boolean { + if (selector.topic === 'worker_lifecycle') { + if ( + selector.worker_ids.length === 0 || + selector.worker_ids.length > MAX_WORKER_IDS_PER_SELECTOR + ) { + return false; + } + const ids = new Set(); + for (const workerId of selector.worker_ids) { + if (!validateIdentifier(workerId, MAX_RESOURCE_ID_BYTES) || ids.has(workerId)) return false; + ids.add(workerId); + } + return true; + } + if (selector.topic === 'worker_protocol') { + return ( + validateIdentifier(selector.worker_id, MAX_RESOURCE_ID_BYTES) && + (selector.runtime_id == null || + validateIdentifier(selector.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + } + return true; +} + +function snapshotMatchesSelector( + snapshot: SubscriptionSnapshot, + selector: EventSubscriptionSelector, +): boolean { + if (!validateSelector(selector)) return false; + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return snapshot.topic === 'workers' && validateWorkers(snapshot.data.workers); + } + if (selector.topic === 'worker_lifecycle') { + const selected = new Set(selector.worker_ids); + return ( + snapshot.topic === 'workers' && + validateWorkers(snapshot.data.workers) && + snapshot.data.workers.every((worker) => selected.has(worker.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return snapshot.topic === 'worker_protocol' && snapshot.data.worker_id === selector.worker_id; + } + return ( + snapshot.topic === 'workspace_workdirs' && + snapshot.data.workdirs.every(validateWorkspaceWorkdir) + ); +} + +function validateEventPayload(payload: SubscriptionEventPayload): boolean { + switch (payload.event) { + case 'worker_upserted': + return validateWorker(payload.data.worker); + case 'worker_removed': + return ( + validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES) && + (payload.data.runtime_id == null || + validateIdentifier(payload.data.runtime_id, MAX_RESOURCE_ID_BYTES)) + ); + case 'worker_protocol': + return validateIdentifier(payload.data.worker_id, MAX_RESOURCE_ID_BYTES); + case 'workdir_upserted': + return validateWorkspaceWorkdir(payload.data.workdir); + case 'workdir_removed': + return validateIdentifier(payload.data.working_directory_id, MAX_RESOURCE_ID_BYTES); + } +} + +function eventPayloadMatchesSelector( + payload: SubscriptionEventPayload, + selector: EventSubscriptionSelector, +): boolean { + if (selector.topic === 'runtime_workers' || selector.topic === 'workspace_workers') { + return payload.event === 'worker_upserted' || payload.event === 'worker_removed'; + } + if (selector.topic === 'worker_lifecycle') { + return ( + (payload.event === 'worker_upserted' && selector.worker_ids.includes(payload.data.worker.worker_id)) || + (payload.event === 'worker_removed' && selector.worker_ids.includes(payload.data.worker_id)) + ); + } + if (selector.topic === 'worker_protocol') { + return payload.event === 'worker_protocol' && payload.data.worker_id === selector.worker_id; + } + return payload.event === 'workdir_upserted' || payload.event === 'workdir_removed'; +} + +function selectorsEqual( + left: EventSubscriptionSelector, + right: EventSubscriptionSelector, +): boolean { + if (left.topic !== right.topic) return false; + if (left.topic === 'worker_lifecycle' && right.topic === 'worker_lifecycle') { + const rightIds = new Set(right.worker_ids); + return left.worker_ids.length === rightIds.size && left.worker_ids.every((workerId) => rightIds.has(workerId)); + } + if (left.topic === 'worker_protocol' && right.topic === 'worker_protocol') { + return ( + left.worker_id === right.worker_id && + (left.runtime_id ?? null) === (right.runtime_id ?? null) + ); + } + return true; +} + +function validateWorkers(workers: SubscriptionWorker[]): boolean { + const identities = new Set(); + for (const worker of workers) { + const identity = `${worker.runtime_id ?? ''}\u0000${worker.worker_id}`; + if (!validateWorker(worker) || identities.has(identity)) return false; + identities.add(identity); + } + return true; +} + +function validateWorker(worker: SubscriptionWorker): boolean { + if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false; + if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) { + return false; + } + if (worker.resource_key != null && !validateIdentifier(worker.resource_key, MAX_RESOURCE_ID_BYTES)) { + return false; + } + const aliases = new Set(); + const workdirIds = new Set(); + for (const attachment of worker.workdir_attachments ?? []) { + if ( + !validateIdentifier(attachment.alias, MAX_RESOURCE_ID_BYTES) || + !validateIdentifier(attachment.working_directory_id, MAX_RESOURCE_ID_BYTES) || + attachment.repository_key == null || + !validateRepositoryKey(attachment.repository_key) || + aliases.has(attachment.alias) || + workdirIds.has(attachment.working_directory_id) + ) { + return false; + } + aliases.add(attachment.alias); + workdirIds.add(attachment.working_directory_id); + } + return true; +} + +function validateWorkspaceWorkdir(workdir: WorkspaceSubscriptionWorkdir): boolean { + return ( + validateIdentifier(workdir.working_directory_id, MAX_RESOURCE_ID_BYTES) && + validateRepositoryKey(workdir.repository_key) && + validateIdentifier(workdir.state, MAX_RESOURCE_ID_BYTES) + ); +} + +function validateIdentifier(value: string, maxBytes: number): boolean { + return ( + value.length > 0 && + utf8.encode(value).byteLength <= maxBytes && + value.trim() === value && + !/\p{Cc}/u.test(value) + ); +} + +function validateRepositoryKey(value: string): boolean { + return ( + utf8.encode(value).byteLength <= 64 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) + ); +} + +function validateBoundedMessage(value: string): boolean { + const bytes = utf8.encode(value).byteLength; + return bytes > 0 && bytes <= MAX_REJECTION_MESSAGE_BYTES; +} diff --git a/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts new file mode 100644 index 000000000..23eaa7a5d --- /dev/null +++ b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { WorkspaceMultiplexer } from './multiplexer'; + +type SocketEvent = 'open' | 'message' | 'error' | 'close'; +type SocketListener = (event: { data?: unknown }) => void; + +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: FakeWebSocket[] = []; + + readyState = FakeWebSocket.CONNECTING; + readonly sent: string[] = []; + readonly closes: Array<[number | undefined, string | undefined]> = []; + readonly #listeners = new Map(); + + constructor(readonly url: URL) { + FakeWebSocket.instances.push(this); + } + + addEventListener(kind: SocketEvent, listener: SocketListener): void { + const listeners = this.#listeners.get(kind) ?? []; + listeners.push(listener); + this.#listeners.set(kind, listeners); + } + + send(value: string): void { + this.sent.push(value); + } + + close(code?: number, reason?: string): void { + this.closes.push([code, reason]); + if (this.readyState === FakeWebSocket.CLOSED) return; + this.readyState = FakeWebSocket.CLOSED; + this.#emit('close'); + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.#emit('open'); + } + + message(value: unknown): void { + this.#emit('message', { data: value }); + } + + #emit(kind: SocketEvent, event: { data?: unknown } = {}): void { + for (const listener of this.#listeners.get(kind) ?? []) listener(event); + } +} + +beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('invalid inbound frame closes before dispatch and reconnects for a fresh snapshot', () => { + vi.useFakeTimers(); + const multiplexer = new WorkspaceMultiplexer('workspace-validation-test'); + const onFrame = vi.fn(); + const onStatus = vi.fn(); + multiplexer.subscribe({ topic: 'workspace_workers' }, { onFrame, onStatus }); + + const first = FakeWebSocket.instances[0]; + expect(first).toBeDefined(); + first.open(); + expect(first.sent).toHaveLength(1); + + first.message( + JSON.stringify({ + protocol_version: 1, + frame: 'event', + message: { + event: 'subscription_closed', + data: { + subscription_id: 'subscription-1', + code: 'lagged', + message: 'untrusted detail', + newer_field: true, + }, + }, + }), + ); + + expect(onFrame).not.toHaveBeenCalled(); + expect(first.closes).toEqual([[1002, 'Invalid workspace protocol frame']]); + expect(onStatus).toHaveBeenLastCalledWith('closed', 'Workspace protocol frame rejected'); + expect(JSON.stringify(onStatus.mock.calls)).not.toContain('untrusted detail'); + + vi.advanceTimersByTime(500); + const second = FakeWebSocket.instances[1]; + expect(second).toBeDefined(); + second.open(); + expect(second.sent).toHaveLength(1); + expect(JSON.parse(second.sent[0]).message.method).toBe('subscribe_events'); + multiplexer.dispose(); +}); + +test('valid subscription closure remains isolated and resubscribes only its selector', () => { + const multiplexer = new WorkspaceMultiplexer('workspace-isolation-test'); + const workersFrame = vi.fn(); + const workersStatus = vi.fn(); + const workdirsFrame = vi.fn(); + const workdirsStatus = vi.fn(); + multiplexer.subscribe( + { topic: 'workspace_workers' }, + { onFrame: workersFrame, onStatus: workersStatus }, + ); + multiplexer.subscribe( + { topic: 'workspace_workdirs' }, + { onFrame: workdirsFrame, onStatus: workdirsStatus }, + ); + + const socket = FakeWebSocket.instances[0]; + socket.open(); + const requests = socket.sent.map((value) => JSON.parse(value)); + expect(requests).toHaveLength(2); + for (const [index, request] of requests.entries()) { + if (request.frame !== 'request' || request.message.method !== 'subscribe_events') { + throw new Error('expected subscribe request'); + } + const selector = request.message.params.selector; + const snapshot = + selector.topic === 'workspace_workers' + ? { topic: 'workers', data: { workers: [] } } + : { topic: 'workspace_workdirs', data: { workdirs: [] } }; + socket.message( + JSON.stringify({ + protocol_version: 1, + frame: 'response', + message: { + result: 'subscribed', + payload: { + request_id: request.message.params.request_id, + subscription_id: `subscription-${index + 1}`, + selector, + snapshot_revision: 1, + snapshot, + }, + }, + }), + ); + } + + const sentBeforeClosure = socket.sent.length; + socket.message( + JSON.stringify({ + protocol_version: 1, + frame: 'event', + message: { + event: 'subscription_closed', + data: { + subscription_id: 'subscription-1', + code: 'lagged', + message: 'resubscribe for a fresh snapshot', + }, + }, + }), + ); + + expect(workersFrame).toHaveBeenCalledTimes(2); + expect(workersStatus).toHaveBeenLastCalledWith( + 'connecting', + 'resubscribe for a fresh snapshot', + ); + expect(workdirsFrame).toHaveBeenCalledTimes(1); + expect(workdirsStatus).not.toHaveBeenCalledWith( + 'connecting', + 'resubscribe for a fresh snapshot', + ); + expect(socket.sent).toHaveLength(sentBeforeClosure + 1); + expect(JSON.parse(socket.sent.at(-1)!).message.params.selector).toEqual({ + topic: 'workspace_workers', + }); + expect(socket.closes).toEqual([]); + multiplexer.dispose(); +}); diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index 5436e1617..a1ca52865 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -5,6 +5,10 @@ import type { SubscriptionFrame, SubscriptionId, } from '$lib/generated/protocol'; +import { + decodeSubscriptionFrame, + subscriptionFrameMatchesSelector, +} from '$lib/generated/protocol-validator'; import { workspaceApiPath } from '$lib/workspace/api/http'; type Listener = { @@ -56,6 +60,7 @@ export class WorkspaceMultiplexer { readonly #runtimeSubscriptions = new Map(); #socket: WebSocket | null = null; #reconnectTimer: ReturnType | null = null; + #disconnectMessage: string | null = null; #closed = false; constructor(workspaceId: string) { @@ -106,17 +111,20 @@ export class WorkspaceMultiplexer { this.#sendSubscribe(subscription); } }); - socket.addEventListener('message', (event) => this.#receive(String(event.data))); + socket.addEventListener('message', (event) => this.#receive(event.data)); socket.addEventListener('error', () => socket.close()); socket.addEventListener('close', () => { if (this.#socket !== socket) return; this.#socket = null; + const disconnectMessage = + this.#disconnectMessage ?? 'Workspace subscription disconnected'; + this.#disconnectMessage = null; this.#requests.clear(); this.#runtimeSubscriptions.clear(); for (const subscription of this.#subscriptions.values()) { subscription.requestId = null; subscription.subscriptionId = null; - subscription.listener.onStatus?.('closed', 'Workspace subscription disconnected'); + subscription.listener.onStatus?.('closed', disconnectMessage); } if (!this.#closed && this.#subscriptions.size > 0) { this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500); @@ -138,25 +146,23 @@ export class WorkspaceMultiplexer { }); } - #receive(text: string): void { - let frame: SubscriptionFrame; - try { - frame = JSON.parse(text) as SubscriptionFrame; - } catch { - this.#socket?.close(); - return; - } - if (frame.protocol_version !== 1) { - this.#socket?.close(); + #receive(input: unknown): void { + const decoded = decodeSubscriptionFrame(input); + if (!decoded.ok) { + this.#rejectInboundFrame(); return; } + const frame = decoded.value; if (frame.frame === 'response' && frame.message.result === 'subscribed') { const clientId = this.#requests.get(frame.message.payload.request_id); const subscription = clientId ? this.#subscriptions.get(clientId) : undefined; if (!clientId || !subscription) return; + if (!subscriptionFrameMatchesSelector(frame, subscription.selector)) { + this.#rejectInboundFrame(); + return; + } this.#requests.delete(frame.message.payload.request_id); const subscriptionId = frame.message.payload.subscription_id; - if (!subscriptionId) return; subscription.subscriptionId = subscriptionId; this.#runtimeSubscriptions.set(subscriptionId, clientId); subscription.listener.onFrame(frame); @@ -175,6 +181,13 @@ export class WorkspaceMultiplexer { if (frame.frame === 'event') { const clientId = this.#runtimeSubscriptions.get(frame.message.data.subscription_id); const subscription = clientId ? this.#subscriptions.get(clientId) : undefined; + if ( + subscription && + !subscriptionFrameMatchesSelector(frame, subscription.selector) + ) { + this.#rejectInboundFrame(); + return; + } subscription?.listener.onFrame(frame); if ( frame.message.event === 'subscription_closed' && @@ -190,6 +203,11 @@ export class WorkspaceMultiplexer { } } + #rejectInboundFrame(): void { + this.#disconnectMessage = 'Workspace protocol frame rejected'; + this.#socket?.close(1002, 'Invalid workspace protocol frame'); + } + #sendWorkerMethod(clientId: string, method: Method): void { const subscription = this.#subscriptions.get(clientId); if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open'); diff --git a/web/workspace/src/lib/workspace/protocol-validator.test.ts b/web/workspace/src/lib/workspace/protocol-validator.test.ts new file mode 100644 index 000000000..801968503 --- /dev/null +++ b/web/workspace/src/lib/workspace/protocol-validator.test.ts @@ -0,0 +1,269 @@ +import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol'; +import { + decodeSubscriptionFrame, + MAX_SUBSCRIPTION_STRING_BYTES, + RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES, + subscriptionFrameMatchesSelector, +} from '$lib/generated/protocol-validator'; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function subscribedFrame(overrides: Record = {}): Record { + return { + protocol_version: 1, + frame: 'response', + message: { + result: 'subscribed', + payload: { + request_id: 'request-1', + subscription_id: 'subscription-1', + selector: { topic: 'workspace_workers' }, + snapshot_revision: 1, + snapshot: { topic: 'workers', data: { workers: [] } }, + ...overrides, + }, + }, + }; +} + +function decode(value: unknown) { + return decodeSubscriptionFrame(JSON.stringify(value)); +} + +Deno.test('generated subscription validator accepts a current exact frame', () => { + const result = decode(subscribedFrame()); + assert(result.ok, `expected valid frame, got ${JSON.stringify(result)}`); + assert(result.value.frame === 'response', 'expected response frame'); +}); + +Deno.test('Rust-serialized compatibility fixtures satisfy the generated Browser contract', () => { + for (const fixture of RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES) { + const result = decode(fixture); + assert(result.ok, `Rust fixture was rejected: ${JSON.stringify(result)}`); + } +}); + +Deno.test('generated subscription validator rejects malformed and newer shapes', () => { + const cases: Array<[string, unknown]> = [ + ['malformed json', '{'], + ['invalid discriminator', { ...subscribedFrame(), frame: 'future' }], + ['missing required field', { frame: 'response', message: subscribedFrame().message }], + ['unknown root field', { ...subscribedFrame(), secret: 'must-not-reflect' }], + [ + 'unknown nested field', + subscribedFrame({ snapshot: { topic: 'workers', data: { workers: [], future: true } } }), + ], + ['wrong field type', subscribedFrame({ snapshot_revision: '1' })], + ['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })], + [ + 'missing serialized Worker defaults', + subscribedFrame({ + snapshot: { + topic: 'workers', + data: { + workers: [ + { worker_id: 'worker-1', subject_revision: 1, state: 'idle' }, + ], + }, + }, + }), + ], + ['unsupported version', { ...subscribedFrame(), protocol_version: 2 }], + [ + 'newer Worker event', + { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { worker_id: 'worker-1', event: { event: 'future_event' } }, + }, + }, + }, + }, + ], + [ + 'out-of-range Rust u32', + { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { + worker_id: 'worker-1', + event: { + event: 'llm_retry', + data: { + llm_call: 1, + failed_attempt: 4_294_967_296, + max_attempts: 2, + wait_ms: 1, + elapsed_ms: 1, + error: 'retry', + }, + }, + }, + }, + }, + }, + }, + ], + ]; + + for (const [name, value] of cases) { + const result = typeof value === 'string' ? decodeSubscriptionFrame(value) : decode(value); + assert(!result.ok, `${name} unexpectedly passed`); + assert(!JSON.stringify(result).includes('must-not-reflect'), `${name} reflected frame content`); + } +}); + +Deno.test('generated subscription validator enforces Rust semantic bounds and selector pairing', () => { + const oversizedIds = Array.from({ length: 257 }, (_, index) => `worker-${index}`); + const cases: Array<[string, unknown]> = [ + [ + 'oversized correlation id', + subscribedFrame({ subscription_id: 'x'.repeat(129) }), + ], + [ + 'oversized rejection message', + { + protocol_version: 1, + frame: 'response', + message: { + result: 'subscription_rejected', + payload: { + request_id: 'request-1', + code: 'invalid_request', + message: 'x'.repeat(1025), + }, + }, + }, + ], + [ + 'oversized selector collection', + subscribedFrame({ + selector: { topic: 'worker_lifecycle', worker_ids: oversizedIds }, + }), + ], + [ + 'selector snapshot mismatch', + subscribedFrame({ + selector: { topic: 'worker_protocol', worker_id: 'worker-1' }, + }), + ], + [ + 'duplicate snapshot worker', + subscribedFrame({ + snapshot: { + topic: 'workers', + data: { + workers: [ + { + worker_id: 'worker-1', + runtime_id: 'runtime-1', + availability: 'observed', + subject_revision: 1, + state: 'idle', + has_running_internal_workers: false, + }, + { + worker_id: 'worker-1', + runtime_id: 'runtime-1', + availability: 'observed', + subject_revision: 2, + state: 'running', + has_running_internal_workers: false, + }, + ], + }, + }, + }), + ], + [ + 'server-to-Browser direction mismatch', + { + protocol_version: 1, + frame: 'request', + message: { + method: 'unsubscribe_events', + params: { request_id: 'request-1', subscription_id: 'subscription-1' }, + }, + }, + ], + ]; + + for (const [name, value] of cases) { + const result = decode(value); + assert(!result.ok, `${name} unexpectedly passed`); + } +}); + +Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => { + const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1); + const result = decode({ + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { + worker_id: 'worker-1', + event: { + event: 'system_item', + data: { item: { [oversizedKey]: true } }, + }, + }, + }, + }, + }, + }); + assert(!result.ok && result.reason === 'aggregate_limit', 'oversized object key passed'); +}); + +Deno.test('stateful selector validation fences a routed event before projection', () => { + const frame: SubscriptionFrame = { + protocol_version: 1, + frame: 'event', + message: { + event: 'event', + data: { + subscription_id: 'subscription-1', + subject_revision: 2, + payload: { + event: 'worker_protocol', + data: { worker_id: 'worker-2', event: { event: 'thinking_start' } }, + }, + }, + }, + }; + const decoded = decode(frame); + assert(decoded.ok, `expected structurally valid event, got ${JSON.stringify(decoded)}`); + const selector: EventSubscriptionSelector = { + topic: 'worker_protocol', + worker_id: 'worker-1', + }; + assert( + !subscriptionFrameMatchesSelector(decoded.value, selector), + 'mismatched Worker event crossed its subscription selector', + ); +});