feat: validate workspace protocol frames in browser
This commit is contained in:
@@ -7,7 +7,7 @@ license.workspace = true
|
|||||||
[features]
|
[features]
|
||||||
default = ["stream"]
|
default = ["stream"]
|
||||||
stream = ["dep:tokio"]
|
stream = ["dep:tokio"]
|
||||||
typescript = ["dep:ts-rs"]
|
typescript = ["dep:ts-rs", "json-schema"]
|
||||||
json-schema = ["dep:schemars"]
|
json-schema = ["dep:schemars"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let path = protocol::typescript::generated_typescript_path();
|
let check = std::env::args()
|
||||||
if let Some(parent) = path.parent() {
|
.skip(1)
|
||||||
std::fs::create_dir_all(parent)?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ impl AuthenticatedInputSource {
|
|||||||
/// of an accepted id must retain the same command kind.
|
/// of an accepted id must retain the same command kind.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct WorkerCommandEnvelope {
|
pub struct WorkerCommandEnvelope {
|
||||||
pub command_id: u64,
|
pub command_id: u64,
|
||||||
}
|
}
|
||||||
@@ -95,6 +96,7 @@ impl WorkerCommandEnvelope {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerCommandKind {
|
pub enum WorkerCommandKind {
|
||||||
Resume,
|
Resume,
|
||||||
@@ -106,6 +108,7 @@ pub enum WorkerCommandKind {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerCommandDisposition {
|
pub enum WorkerCommandDisposition {
|
||||||
Accepted,
|
Accepted,
|
||||||
@@ -116,6 +119,7 @@ pub enum WorkerCommandDisposition {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct WorkerCommandAcknowledgement {
|
pub struct WorkerCommandAcknowledgement {
|
||||||
pub command_id: u64,
|
pub command_id: u64,
|
||||||
pub command: WorkerCommandKind,
|
pub command: WorkerCommandKind,
|
||||||
@@ -212,6 +216,7 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||||
pub enum Method {
|
pub enum Method {
|
||||||
/// Durably accept typed input for immediate activation while the Worker is Idle.
|
/// 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
|
/// Authenticated transport form of Submit. Trusted adapters replace
|
||||||
/// public Submit before forwarding it to the Worker.
|
/// public Submit before forwarding it to the Worker.
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(skip))]
|
||||||
SubmitTracked {
|
SubmitTracked {
|
||||||
submission_request_id: String,
|
submission_request_id: String,
|
||||||
input: Vec<Segment>,
|
input: Vec<Segment>,
|
||||||
@@ -245,6 +251,7 @@ pub enum Method {
|
|||||||
},
|
},
|
||||||
/// Authenticated transport form of Notify.
|
/// Authenticated transport form of Notify.
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(skip))]
|
||||||
NotifyTracked {
|
NotifyTracked {
|
||||||
notification_request_id: String,
|
notification_request_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
@@ -344,6 +351,7 @@ pub enum Method {
|
|||||||
/// child Worker).
|
/// child Worker).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum WorkerEvent {
|
pub enum WorkerEvent {
|
||||||
/// Child finished one turn and is back to IDLE.
|
/// Child finished one turn and is back to IDLE.
|
||||||
@@ -685,6 +693,7 @@ impl Method {
|
|||||||
/// protocol subjects.
|
/// protocol subjects.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InternalWorkerKind {
|
pub enum InternalWorkerKind {
|
||||||
SubWorker,
|
SubWorker,
|
||||||
@@ -694,6 +703,7 @@ pub enum InternalWorkerKind {
|
|||||||
/// Stable parent-owned lifecycle for one compaction run.
|
/// Stable parent-owned lifecycle for one compaction run.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct CompactionLifecycle {
|
pub struct CompactionLifecycle {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub compaction_id: String,
|
pub compaction_id: String,
|
||||||
@@ -715,6 +725,7 @@ pub struct CompactionLifecycle {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompactionLifecycleState {
|
pub enum CompactionLifecycleState {
|
||||||
Running,
|
Running,
|
||||||
@@ -727,6 +738,7 @@ pub enum CompactionLifecycleState {
|
|||||||
/// `name` is display-only; `session_id` is the identity used by clients.
|
/// `name` is display-only; `session_id` is the identity used by clients.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct InternalWorkerRef {
|
pub struct InternalWorkerRef {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -739,11 +751,13 @@ pub struct InternalWorkerRef {
|
|||||||
/// `Event::InternalWorker` updates that raced with parent snapshot assembly.
|
/// `Event::InternalWorker` updates that raced with parent snapshot assembly.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct InternalWorkerSnapshot {
|
pub struct InternalWorkerSnapshot {
|
||||||
pub worker: InternalWorkerRef,
|
pub worker: InternalWorkerRef,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
pub session: SessionSnapshot,
|
pub session: SessionSnapshot,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -755,6 +769,7 @@ pub struct InternalWorkerSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ToolResultDisposition {
|
pub enum ToolResultDisposition {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -768,6 +783,7 @@ pub enum ToolResultDisposition {
|
|||||||
/// Durable acceptance result for one idempotent Submit request.
|
/// Durable acceptance result for one idempotent Submit request.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubmissionDisposition {
|
pub enum SubmissionDisposition {
|
||||||
Started,
|
Started,
|
||||||
@@ -780,6 +796,7 @@ pub enum SubmissionDisposition {
|
|||||||
/// provenance remain in the session log and are intentionally not exposed.
|
/// provenance remain in the session log and are intentionally not exposed.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct PendingSubmissionSummary {
|
pub struct PendingSubmissionSummary {
|
||||||
pub submission_id: String,
|
pub submission_id: String,
|
||||||
pub accepted_at_ms: u64,
|
pub accepted_at_ms: u64,
|
||||||
@@ -790,13 +807,17 @@ pub struct PendingSubmissionSummary {
|
|||||||
/// Revisioned session-owned FIFO projection used by snapshots and live events.
|
/// Revisioned session-owned FIFO projection used by snapshots and live events.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct PendingSubmissionsSnapshot {
|
pub struct PendingSubmissionsSnapshot {
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub notification_count: u32,
|
pub notification_count: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub head_id: Option<String>,
|
pub head_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub submissions: Vec<PendingSubmissionSummary>,
|
pub submissions: Vec<PendingSubmissionSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,14 +828,17 @@ pub struct PendingSubmissionsSnapshot {
|
|||||||
/// to replay the durable log format.
|
/// to replay the durable log format.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SessionSnapshot {
|
pub struct SessionSnapshot {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub pending_submissions: PendingSubmissionsSnapshot,
|
pub pending_submissions: PendingSubmissionsSnapshot,
|
||||||
pub entries: Vec<SessionSnapshotEntry>,
|
pub entries: Vec<SessionSnapshotEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SessionEntryProvenance {
|
pub enum SessionEntryProvenance {
|
||||||
HumanInput,
|
HumanInput,
|
||||||
@@ -829,6 +853,7 @@ pub enum SessionEntryProvenance {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SessionSnapshotEntry {
|
pub struct SessionSnapshotEntry {
|
||||||
/// Stable identity from durable history metadata, or a deterministic
|
/// Stable identity from durable history metadata, or a deterministic
|
||||||
/// identity derived from the legacy segment and log position.
|
/// identity derived from the legacy segment and log position.
|
||||||
@@ -844,6 +869,7 @@ pub struct SessionSnapshotEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum SessionSnapshotEntryData {
|
pub enum SessionSnapshotEntryData {
|
||||||
UserInput {
|
UserInput {
|
||||||
@@ -881,6 +907,7 @@ pub enum SessionSnapshotEntryData {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SessionMessageRole {
|
pub enum SessionMessageRole {
|
||||||
User,
|
User,
|
||||||
@@ -889,6 +916,7 @@ pub enum SessionMessageRole {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum SessionContentPart {
|
pub enum SessionContentPart {
|
||||||
Text { text: String },
|
Text { text: String },
|
||||||
@@ -897,6 +925,7 @@ pub enum SessionContentPart {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SessionToolAttachment {
|
pub struct SessionToolAttachment {
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
||||||
@@ -906,6 +935,7 @@ pub struct SessionToolAttachment {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// Durable Submit acceptance. New submissions are accepted only while Idle,
|
/// Durable Submit acceptance. New submissions are accepted only while Idle,
|
||||||
@@ -1069,6 +1099,7 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
disposition: Option<ToolResultDisposition>,
|
disposition: Option<ToolResultDisposition>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
},
|
},
|
||||||
/// Token accounting for one LLM request.
|
/// Token accounting for one LLM request.
|
||||||
@@ -1233,6 +1264,7 @@ pub enum Event {
|
|||||||
/// human-readable.
|
/// human-readable.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct Alert {
|
pub struct Alert {
|
||||||
pub level: AlertLevel,
|
pub level: AlertLevel,
|
||||||
pub source: AlertSource,
|
pub source: AlertSource,
|
||||||
@@ -1243,6 +1275,7 @@ pub struct Alert {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct MemoryWorkerEvent {
|
pub struct MemoryWorkerEvent {
|
||||||
pub worker: String,
|
pub worker: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
@@ -1257,6 +1290,7 @@ pub struct MemoryWorkerEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AlertLevel {
|
pub enum AlertLevel {
|
||||||
Warn,
|
Warn,
|
||||||
@@ -1265,6 +1299,7 @@ pub enum AlertLevel {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AlertSource {
|
pub enum AlertSource {
|
||||||
Worker,
|
Worker,
|
||||||
@@ -1278,6 +1313,7 @@ pub enum AlertSource {
|
|||||||
/// Mirrors the completion prefix sigil: `@` → `File`.
|
/// Mirrors the completion prefix sigil: `@` → `File`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompletionKind {
|
pub enum CompletionKind {
|
||||||
File,
|
File,
|
||||||
@@ -1288,22 +1324,27 @@ pub enum CompletionKind {
|
|||||||
/// `value` is a path (file kind).
|
/// `value` is a path (file kind).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct CompletionEntry {
|
pub struct CompletionEntry {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct RewindTargetId {
|
pub struct RewindTargetId {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(with = "String"))]
|
||||||
pub segment_id: uuid::Uuid,
|
pub segment_id: uuid::Uuid,
|
||||||
pub user_input_entry_index: usize,
|
pub user_input_entry_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct RewindTarget {
|
pub struct RewindTarget {
|
||||||
pub id: RewindTargetId,
|
pub id: RewindTargetId,
|
||||||
pub expected_head_entries: usize,
|
pub expected_head_entries: usize,
|
||||||
@@ -1320,6 +1361,7 @@ pub struct RewindTarget {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct RewindSummary {
|
pub struct RewindSummary {
|
||||||
pub truncated_to_entries: usize,
|
pub truncated_to_entries: usize,
|
||||||
pub discarded_entries: usize,
|
pub discarded_entries: usize,
|
||||||
@@ -1330,6 +1372,7 @@ pub struct RewindSummary {
|
|||||||
/// state only and are never appended to Worker history.
|
/// state only and are never appended to Worker history.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CommandStatus {
|
pub enum CommandStatus {
|
||||||
Running,
|
Running,
|
||||||
@@ -1341,6 +1384,7 @@ pub enum CommandStatus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CommandStream {
|
pub enum CommandStream {
|
||||||
Stdout,
|
Stdout,
|
||||||
@@ -1349,6 +1393,7 @@ pub enum CommandStream {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct CommandStreamSlice {
|
pub struct CommandStreamSlice {
|
||||||
pub start_offset: u64,
|
pub start_offset: u64,
|
||||||
pub end_offset: u64,
|
pub end_offset: u64,
|
||||||
@@ -1358,6 +1403,7 @@ pub struct CommandStreamSlice {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct CommandSnapshot {
|
pub struct CommandSnapshot {
|
||||||
pub command_id: String,
|
pub command_id: String,
|
||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
@@ -1372,6 +1418,7 @@ pub struct CommandSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum CommandEvent {
|
pub enum CommandEvent {
|
||||||
Started {
|
Started {
|
||||||
@@ -1399,6 +1446,7 @@ pub enum CommandEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompactionPhase {
|
pub enum CompactionPhase {
|
||||||
Preparing,
|
Preparing,
|
||||||
@@ -1408,6 +1456,7 @@ pub enum CompactionPhase {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompactionTrigger {
|
pub enum CompactionTrigger {
|
||||||
Manual,
|
Manual,
|
||||||
@@ -1417,6 +1466,7 @@ pub enum CompactionTrigger {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct InFlightCompaction {
|
pub struct InFlightCompaction {
|
||||||
pub phase: CompactionPhase,
|
pub phase: CompactionPhase,
|
||||||
pub started_at_ms: u64,
|
pub started_at_ms: u64,
|
||||||
@@ -1432,6 +1482,7 @@ pub struct InFlightCompaction {
|
|||||||
/// entries.
|
/// entries.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct InFlightSnapshot {
|
pub struct InFlightSnapshot {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub blocks: Vec<InFlightBlock>,
|
pub blocks: Vec<InFlightBlock>,
|
||||||
@@ -1453,6 +1504,7 @@ impl InFlightSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum InFlightBlock {
|
pub enum InFlightBlock {
|
||||||
Text {
|
Text {
|
||||||
@@ -1476,6 +1528,7 @@ pub enum InFlightBlock {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InFlightToolCallState {
|
pub enum InFlightToolCallState {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -1497,6 +1550,7 @@ impl InFlightToolCallState {
|
|||||||
/// their own view of the manifest.
|
/// their own view of the manifest.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct Greeting {
|
pub struct Greeting {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
@@ -1506,9 +1560,11 @@ pub struct Greeting {
|
|||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
/// Model context window in tokens. Always filled by the Worker greeting.
|
/// Model context window in tokens. Always filled by the Worker greeting.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
/// Estimated current session context tokens at connect time.
|
/// Estimated current session context tokens at connect time.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub context_tokens: u64,
|
pub context_tokens: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1518,6 +1574,7 @@ pub struct Greeting {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerStatus {
|
pub enum WorkerStatus {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -1529,6 +1586,7 @@ pub enum WorkerStatus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TurnResult {
|
pub enum TurnResult {
|
||||||
Finished,
|
Finished,
|
||||||
@@ -1543,6 +1601,7 @@ pub enum TurnResult {
|
|||||||
/// following Turn entry, not by the marker itself.
|
/// following Turn entry, not by the marker itself.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InvokeKind {
|
pub enum InvokeKind {
|
||||||
/// `Method::Submit` — a user submission.
|
/// `Method::Submit` — a user submission.
|
||||||
@@ -1563,6 +1622,7 @@ pub enum InvokeKind {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RunResult {
|
pub enum RunResult {
|
||||||
Finished,
|
Finished,
|
||||||
@@ -1577,6 +1637,7 @@ pub enum RunResult {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ErrorCode {
|
pub enum ErrorCode {
|
||||||
AlreadyRunning,
|
AlreadyRunning,
|
||||||
@@ -1599,6 +1660,7 @@ pub enum ErrorCode {
|
|||||||
/// A single allow or deny rule inside a scope configuration.
|
/// A single allow or deny rule inside a scope configuration.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct ScopeRule {
|
pub struct ScopeRule {
|
||||||
/// Target path. Must be absolute by the time a `Scope` is built from
|
/// Target path. Must be absolute by the time a `Scope` is built from
|
||||||
/// this rule — relative paths are resolved per-layer against the
|
/// this rule — relative paths are resolved per-layer against the
|
||||||
@@ -1615,6 +1677,7 @@ pub struct ScopeRule {
|
|||||||
/// Which path identity an allow rule uses when symbolic links are
|
/// Which path identity an allow rule uses when symbolic links are
|
||||||
/// encountered. Deny rules always inspect both identities.
|
/// encountered. Deny rules always inspect both identities.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub symlink_policy: SymlinkPolicy,
|
pub symlink_policy: SymlinkPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1629,6 +1692,7 @@ fn default_recursive() -> bool {
|
|||||||
/// path as presented through the Workdir, even when it aliases another target.
|
/// path as presented through the Workdir, even when it aliases another target.
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum SymlinkPolicy {
|
pub enum SymlinkPolicy {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -1644,6 +1708,7 @@ pub enum SymlinkPolicy {
|
|||||||
/// below** the stated level.
|
/// below** the stated level.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Permission {
|
pub enum Permission {
|
||||||
Read,
|
Read,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ use serde::{Deserialize, Deserializer, Serialize};
|
|||||||
use crate::Event as WorkerProtocolEvent;
|
use crate::Event as WorkerProtocolEvent;
|
||||||
|
|
||||||
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1;
|
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_CORRELATION_ID_BYTES: usize = 128;
|
||||||
pub const MAX_RESOURCE_ID_BYTES: usize = 256;
|
pub const MAX_RESOURCE_ID_BYTES: usize = 256;
|
||||||
pub const MAX_WORKER_IDS_PER_SELECTOR: 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) => {
|
($name:ident, $field:literal, $max:expr) => {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct $name(String);
|
pub struct $name(String);
|
||||||
|
|
||||||
impl $name {
|
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.
|
/// semantics is lexical, so equivalent selectors aggregate to one upstream key.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionWorkerIds(Vec<SubscriptionWorkerId>);
|
pub struct SubscriptionWorkerIds(Vec<SubscriptionWorkerId>);
|
||||||
|
|
||||||
impl SubscriptionWorkerIds {
|
impl SubscriptionWorkerIds {
|
||||||
@@ -257,6 +266,7 @@ impl<'de> Deserialize<'de> for SubscriptionWorkerIds {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(tag = "topic", rename_all = "snake_case")]
|
#[serde(tag = "topic", rename_all = "snake_case")]
|
||||||
pub enum EventSubscriptionSelector {
|
pub enum EventSubscriptionSelector {
|
||||||
RuntimeWorkers,
|
RuntimeWorkers,
|
||||||
@@ -308,6 +318,7 @@ impl EventSubscriptionSelector {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionFrame {
|
pub struct SubscriptionFrame {
|
||||||
pub protocol_version: u16,
|
pub protocol_version: u16,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -336,6 +347,7 @@ impl SubscriptionFrame {
|
|||||||
/// remain typed within each lane.
|
/// remain typed within each lane.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "frame", content = "message", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionFramePayload {
|
pub enum SubscriptionFramePayload {
|
||||||
Request(SubscriptionRequest),
|
Request(SubscriptionRequest),
|
||||||
@@ -357,6 +369,7 @@ impl SubscriptionFramePayload {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionWorkerProtocolMethod {
|
pub struct SubscriptionWorkerProtocolMethod {
|
||||||
pub subscription_id: SubscriptionId,
|
pub subscription_id: SubscriptionId,
|
||||||
pub method: crate::Method,
|
pub method: crate::Method,
|
||||||
@@ -370,6 +383,7 @@ impl SubscriptionWorkerProtocolMethod {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionRequest {
|
pub enum SubscriptionRequest {
|
||||||
SubscribeEvents {
|
SubscribeEvents {
|
||||||
@@ -405,6 +419,7 @@ impl SubscriptionRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "result", content = "payload", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionResponse {
|
pub enum SubscriptionResponse {
|
||||||
Subscribed {
|
Subscribed {
|
||||||
@@ -467,6 +482,7 @@ impl SubscriptionResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubscriptionRejectionCode {
|
pub enum SubscriptionRejectionCode {
|
||||||
InvalidRequest,
|
InvalidRequest,
|
||||||
@@ -480,6 +496,7 @@ pub enum SubscriptionRejectionCode {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubscriptionTerminationCode {
|
pub enum SubscriptionTerminationCode {
|
||||||
Lagged,
|
Lagged,
|
||||||
@@ -490,6 +507,7 @@ pub enum SubscriptionTerminationCode {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionEvent {
|
pub enum SubscriptionEvent {
|
||||||
Event {
|
Event {
|
||||||
@@ -551,6 +569,7 @@ impl SubscriptionEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubscriptionWorkerState {
|
pub enum SubscriptionWorkerState {
|
||||||
Idle,
|
Idle,
|
||||||
@@ -564,8 +583,8 @@ pub enum SubscriptionWorkerState {
|
|||||||
/// `Unavailable` deliberately does not imply `Stopped`: it means the last observed
|
/// `Unavailable` deliberately does not imply `Stopped`: it means the last observed
|
||||||
/// lifecycle state is retained while the Runtime observation source is unavailable.
|
/// lifecycle state is retained while the Runtime observation source is unavailable.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[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 = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SubscriptionWorkerAvailability {
|
pub enum SubscriptionWorkerAvailability {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -575,6 +594,7 @@ pub enum SubscriptionWorkerAvailability {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionWorkerWorkdirAttachment {
|
pub struct SubscriptionWorkerWorkdirAttachment {
|
||||||
/// Stable Worker-local routing alias.
|
/// Stable Worker-local routing alias.
|
||||||
pub alias: String,
|
pub alias: String,
|
||||||
@@ -582,6 +602,7 @@ pub struct SubscriptionWorkerWorkdirAttachment {
|
|||||||
/// omit this field and require `repository_key` from the Server projection.
|
/// omit this field and require `repository_key` from the Server projection.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(skip))]
|
||||||
pub repository_id: Option<String>,
|
pub repository_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub repository_key: Option<String>,
|
pub repository_key: Option<String>,
|
||||||
@@ -610,6 +631,7 @@ impl SubscriptionWorkerWorkdirAttachment {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionWorker {
|
pub struct SubscriptionWorker {
|
||||||
pub worker_id: SubscriptionWorkerId,
|
pub worker_id: SubscriptionWorkerId,
|
||||||
/// Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
|
/// Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
|
||||||
@@ -623,6 +645,7 @@ pub struct SubscriptionWorker {
|
|||||||
/// Freshness of the Runtime-backed observation carried by this projection.
|
/// Freshness of the Runtime-backed observation carried by this projection.
|
||||||
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub availability: SubscriptionWorkerAvailability,
|
pub availability: SubscriptionWorkerAvailability,
|
||||||
/// Producer-owned monotonic revision for this Worker subject.
|
/// Producer-owned monotonic revision for this Worker subject.
|
||||||
pub subject_revision: u64,
|
pub subject_revision: u64,
|
||||||
@@ -633,6 +656,7 @@ pub struct SubscriptionWorker {
|
|||||||
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
|
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
|
||||||
pub state: SubscriptionWorkerState,
|
pub state: SubscriptionWorkerState,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(required))]
|
||||||
pub has_running_internal_workers: bool,
|
pub has_running_internal_workers: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
@@ -674,12 +698,14 @@ impl SubscriptionWorker {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SubscriptionWorkdir {
|
pub struct SubscriptionWorkdir {
|
||||||
pub working_directory_id: SubscriptionWorkdirId,
|
pub working_directory_id: SubscriptionWorkdirId,
|
||||||
/// Runtime-internal Repository id. Workspace-facing TypeScript contracts
|
/// Runtime-internal Repository id. Workspace-facing TypeScript contracts
|
||||||
/// omit this field and require `repository_key` from the Server projection.
|
/// omit this field and require `repository_key` from the Server projection.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(skip))]
|
||||||
pub repository_id: Option<String>,
|
pub repository_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub repository_key: Option<String>,
|
pub repository_key: Option<String>,
|
||||||
@@ -709,6 +735,7 @@ impl SubscriptionWorkdir {
|
|||||||
/// enter this DTO; Workspace Server must resolve the required Repository key.
|
/// enter this DTO; Workspace Server must resolve the required Repository key.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct WorkspaceSubscriptionWorkdir {
|
pub struct WorkspaceSubscriptionWorkdir {
|
||||||
pub working_directory_id: SubscriptionWorkdirId,
|
pub working_directory_id: SubscriptionWorkdirId,
|
||||||
pub repository_key: String,
|
pub repository_key: String,
|
||||||
@@ -726,6 +753,7 @@ impl WorkspaceSubscriptionWorkdir {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "topic", content = "data", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionSnapshot {
|
pub enum SubscriptionSnapshot {
|
||||||
Workers {
|
Workers {
|
||||||
@@ -789,6 +817,7 @@ impl SubscriptionSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[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")]
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
pub enum SubscriptionEventPayload {
|
pub enum SubscriptionEventPayload {
|
||||||
WorkerUpserted {
|
WorkerUpserted {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
use ts_rs::{Config, TS};
|
use ts_rs::{Config, TS};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,9 +18,13 @@ use crate::{
|
|||||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
||||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
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,
|
SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker,
|
||||||
SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds,
|
SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds,
|
||||||
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
|
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
|
||||||
@@ -28,12 +33,19 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
|
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.
|
/// Repository-relative destination for the Workspace web protocol bindings.
|
||||||
pub fn generated_typescript_path() -> PathBuf {
|
pub fn generated_typescript_path() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
|
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.
|
/// 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
|
/// Rust DTOs in this crate remain the source of truth; this function is used by
|
||||||
@@ -134,6 +146,144 @@ pub fn generated_protocol_types() -> String {
|
|||||||
normalize_typescript(output)
|
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");
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if object.get("type").and_then(Value::as_str) == Some("object")
|
||||||
|
&& object.contains_key("properties")
|
||||||
|
{
|
||||||
|
object.insert("additionalProperties".to_string(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String, Value>) {
|
||||||
|
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 {
|
fn normalize_typescript(output: String) -> String {
|
||||||
let mut lines = output.lines().map(str::trim_end).collect::<Vec<_>>();
|
let mut lines = output.lines().map(str::trim_end).collect::<Vec<_>>();
|
||||||
while lines.last() == Some(&"") {
|
while lines.last() == Some(&"") {
|
||||||
@@ -180,4 +330,18 @@ mod tests {
|
|||||||
"generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`"
|
"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`"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
__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__;
|
||||||
|
const utf8 = new TextEncoder();
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
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.values(value);
|
||||||
|
return (
|
||||||
|
entries.length <= MAX_SUBSCRIPTION_COLLECTION_ITEMS &&
|
||||||
|
entries.every((entry) => 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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
const workdirIds = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||||
|
|||||||
@@ -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.
|
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 validator closes every generated object shape, rejects unknown tagged variants and unsafe integers, applies Rust-owned aggregate bounds, and mirrors the Browser-relevant `SubscriptionFrame::validate()` selector/payload checks. 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
|
## 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.
|
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.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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<SocketEvent, SocketListener[]>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -5,6 +5,10 @@ import type {
|
|||||||
SubscriptionFrame,
|
SubscriptionFrame,
|
||||||
SubscriptionId,
|
SubscriptionId,
|
||||||
} from '$lib/generated/protocol';
|
} from '$lib/generated/protocol';
|
||||||
|
import {
|
||||||
|
decodeSubscriptionFrame,
|
||||||
|
subscriptionFrameMatchesSelector,
|
||||||
|
} from '$lib/generated/protocol-validator';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
|
|
||||||
type Listener = {
|
type Listener = {
|
||||||
@@ -56,6 +60,7 @@ export class WorkspaceMultiplexer {
|
|||||||
readonly #runtimeSubscriptions = new Map<string, string>();
|
readonly #runtimeSubscriptions = new Map<string, string>();
|
||||||
#socket: WebSocket | null = null;
|
#socket: WebSocket | null = null;
|
||||||
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
#disconnectMessage: string | null = null;
|
||||||
#closed = false;
|
#closed = false;
|
||||||
|
|
||||||
constructor(workspaceId: string) {
|
constructor(workspaceId: string) {
|
||||||
@@ -106,17 +111,20 @@ export class WorkspaceMultiplexer {
|
|||||||
this.#sendSubscribe(subscription);
|
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('error', () => socket.close());
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
if (this.#socket !== socket) return;
|
if (this.#socket !== socket) return;
|
||||||
this.#socket = null;
|
this.#socket = null;
|
||||||
|
const disconnectMessage =
|
||||||
|
this.#disconnectMessage ?? 'Workspace subscription disconnected';
|
||||||
|
this.#disconnectMessage = null;
|
||||||
this.#requests.clear();
|
this.#requests.clear();
|
||||||
this.#runtimeSubscriptions.clear();
|
this.#runtimeSubscriptions.clear();
|
||||||
for (const subscription of this.#subscriptions.values()) {
|
for (const subscription of this.#subscriptions.values()) {
|
||||||
subscription.requestId = null;
|
subscription.requestId = null;
|
||||||
subscription.subscriptionId = null;
|
subscription.subscriptionId = null;
|
||||||
subscription.listener.onStatus?.('closed', 'Workspace subscription disconnected');
|
subscription.listener.onStatus?.('closed', disconnectMessage);
|
||||||
}
|
}
|
||||||
if (!this.#closed && this.#subscriptions.size > 0) {
|
if (!this.#closed && this.#subscriptions.size > 0) {
|
||||||
this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500);
|
this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500);
|
||||||
@@ -138,25 +146,23 @@ export class WorkspaceMultiplexer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#receive(text: string): void {
|
#receive(input: unknown): void {
|
||||||
let frame: SubscriptionFrame;
|
const decoded = decodeSubscriptionFrame(input);
|
||||||
try {
|
if (!decoded.ok) {
|
||||||
frame = JSON.parse(text) as SubscriptionFrame;
|
this.#rejectInboundFrame();
|
||||||
} catch {
|
|
||||||
this.#socket?.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (frame.protocol_version !== 1) {
|
|
||||||
this.#socket?.close();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const frame = decoded.value;
|
||||||
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
||||||
const clientId = this.#requests.get(frame.message.payload.request_id);
|
const clientId = this.#requests.get(frame.message.payload.request_id);
|
||||||
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
||||||
if (!clientId || !subscription) return;
|
if (!clientId || !subscription) return;
|
||||||
|
if (!subscriptionFrameMatchesSelector(frame, subscription.selector)) {
|
||||||
|
this.#rejectInboundFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.#requests.delete(frame.message.payload.request_id);
|
this.#requests.delete(frame.message.payload.request_id);
|
||||||
const subscriptionId = frame.message.payload.subscription_id;
|
const subscriptionId = frame.message.payload.subscription_id;
|
||||||
if (!subscriptionId) return;
|
|
||||||
subscription.subscriptionId = subscriptionId;
|
subscription.subscriptionId = subscriptionId;
|
||||||
this.#runtimeSubscriptions.set(subscriptionId, clientId);
|
this.#runtimeSubscriptions.set(subscriptionId, clientId);
|
||||||
subscription.listener.onFrame(frame);
|
subscription.listener.onFrame(frame);
|
||||||
@@ -175,6 +181,13 @@ export class WorkspaceMultiplexer {
|
|||||||
if (frame.frame === 'event') {
|
if (frame.frame === 'event') {
|
||||||
const clientId = this.#runtimeSubscriptions.get(frame.message.data.subscription_id);
|
const clientId = this.#runtimeSubscriptions.get(frame.message.data.subscription_id);
|
||||||
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
||||||
|
if (
|
||||||
|
subscription &&
|
||||||
|
!subscriptionFrameMatchesSelector(frame, subscription.selector)
|
||||||
|
) {
|
||||||
|
this.#rejectInboundFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
subscription?.listener.onFrame(frame);
|
subscription?.listener.onFrame(frame);
|
||||||
if (
|
if (
|
||||||
frame.message.event === 'subscription_closed' &&
|
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 {
|
#sendWorkerMethod(clientId: string, method: Method): void {
|
||||||
const subscription = this.#subscriptions.get(clientId);
|
const subscription = this.#subscriptions.get(clientId);
|
||||||
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
|
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol';
|
||||||
|
import {
|
||||||
|
decodeSubscriptionFrame,
|
||||||
|
subscriptionFrameMatchesSelector,
|
||||||
|
} from '$lib/generated/protocol-validator';
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
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('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 })],
|
||||||
|
['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' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
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('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',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user