From d2b916602eca8250f82755d1f8fe7b5ab5b16899 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 11:27:06 +0000 Subject: [PATCH] feat(session-types): the fly-session-types crate: domain scalars, closed enums, method payloads, canonical JSON and digests The CONTRACT-01 type definitions, committed early so the session slice can build on them while the fixtures and cross-language tests are still being written. - scalar: Scope, RationalNs (reduced, positive denominator, 0/1 zero, checked arithmetic and the step-v1 accumulator), SchemaRef, TypedValue with its 32-KiB canonical-JSON cap, and the four identities that must not be confused (bus callId, domain requestId, artifact identity, delivery/hold owner token) as distinct types. Id, U64 and Digest call into flybus::wire instead of forking the encodings. - canonical: RFC 8785 canonical JSON, SHA-256 digests, the operation key and the canonical body rules of ipc-v1 section 5, with bus identities refused in a body. - rpc, workers, media, publishing: the payloads of ipc-v1, workers-v1, state-media-v1 and publishing-v1, each with a validate step for the documented ranges, uniqueness, ordering and bounds. - schema: the canonical schema set, whose digest is contractDigest, declared as data so source formatting cannot change it. - trace: the step-v1 section 8 record, behaviour separated from operational metadata. - seed, checkpoint: the seed derivation and FLYSESS1 envelope layout. --- services/flysim/Cargo.lock | 10 + services/flysim/Cargo.toml | 8 +- .../crates/fly-session-types/Cargo.toml | 16 + .../crates/fly-session-types/src/canonical.rs | 273 ++ .../fly-session-types/src/checkpoint.rs | 368 +++ .../crates/fly-session-types/src/fixtures.rs | 108 + .../crates/fly-session-types/src/lib.rs | 56 + .../crates/fly-session-types/src/media.rs | 716 +++++ .../fly-session-types/src/publishing.rs | 450 +++ .../crates/fly-session-types/src/rpc.rs | 420 +++ .../crates/fly-session-types/src/scalar.rs | 725 +++++ .../crates/fly-session-types/src/schema.rs | 1109 ++++++++ .../crates/fly-session-types/src/seed.rs | 70 + .../crates/fly-session-types/src/trace.rs | 486 ++++ .../crates/fly-session-types/src/workers.rs | 2405 +++++++++++++++++ 15 files changed, 7219 insertions(+), 1 deletion(-) create mode 100644 services/flysim/crates/fly-session-types/Cargo.toml create mode 100644 services/flysim/crates/fly-session-types/src/canonical.rs create mode 100644 services/flysim/crates/fly-session-types/src/checkpoint.rs create mode 100644 services/flysim/crates/fly-session-types/src/fixtures.rs create mode 100644 services/flysim/crates/fly-session-types/src/lib.rs create mode 100644 services/flysim/crates/fly-session-types/src/media.rs create mode 100644 services/flysim/crates/fly-session-types/src/publishing.rs create mode 100644 services/flysim/crates/fly-session-types/src/rpc.rs create mode 100644 services/flysim/crates/fly-session-types/src/scalar.rs create mode 100644 services/flysim/crates/fly-session-types/src/schema.rs create mode 100644 services/flysim/crates/fly-session-types/src/seed.rs create mode 100644 services/flysim/crates/fly-session-types/src/trace.rs create mode 100644 services/flysim/crates/fly-session-types/src/workers.rs diff --git a/services/flysim/Cargo.lock b/services/flysim/Cargo.lock index b2a05e3..7915fa9 100644 --- a/services/flysim/Cargo.lock +++ b/services/flysim/Cargo.lock @@ -416,6 +416,16 @@ dependencies = [ "serde", ] +[[package]] +name = "fly-session-types" +version = "0.1.1" +dependencies = [ + "flybus", + "ryu-js", + "serde_json", + "sha2", +] + [[package]] name = "flybrain-core" version = "0.1.1" diff --git a/services/flysim/Cargo.toml b/services/flysim/Cargo.toml index 79b775e..68d2cdd 100644 --- a/services/flysim/Cargo.toml +++ b/services/flysim/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "3" -members = ["crates/flybrain-core", "crates/flybrain-gb", "crates/flybus", "crates/flysim"] +members = [ + "crates/fly-session-types", + "crates/flybrain-core", + "crates/flybrain-gb", + "crates/flybus", + "crates/flysim", +] [workspace.package] version = "0.1.1" diff --git a/services/flysim/crates/fly-session-types/Cargo.toml b/services/flysim/crates/fly-session-types/Cargo.toml new file mode 100644 index 0000000..bcb29c8 --- /dev/null +++ b/services/flysim/crates/fly-session-types/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fly-session-types" +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +publish = false +description = "Session domain scalars, closed enums, method payloads, canonical JSON digests and the step trace format (session-framework CONTRACT-01)." + +[dependencies] +# The bus owns the Id/U64/Digest encodings, strict JSON and ArtifactRef; this crate reuses +# them rather than forking their semantics. +flybus = { path = "../flybus" } +ryu-js.workspace = true +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/services/flysim/crates/fly-session-types/src/canonical.rs b/services/flysim/crates/fly-session-types/src/canonical.rs new file mode 100644 index 0000000..8b34210 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/canonical.rs @@ -0,0 +1,273 @@ +//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5. +//! +//! One serialization, two languages: keys sorted by UTF-16 code unit, numbers printed by the +//! ECMAScript `Number::toString` algorithm (so a JavaScript `JSON.stringify` over the same +//! sorted tree produces the same bytes), strings escaped the way `JSON.stringify` escapes +//! them, no insignificant whitespace. A digest is the SHA-256 of those bytes, lowercase hex. +//! +//! Numbers outside the exactly representable double range are refused rather than rounded: +//! every counter and clock in these contracts is a `U64` decimal string, so a JSON number +//! larger than 2^53-1 is a schema error, not something to canonicalize approximately. + +use serde_json::{Number, Value}; +use sha2::{Digest as _, Sha256}; + +use crate::scalar::{Result, Scope, err, wire_err}; + +/// The largest integer a double represents exactly. +pub const MAX_EXACT_INTEGER: i64 = 9_007_199_254_740_991; + +/// The bus envelope ceiling every domain message must also fit (bus-v1 section 4). +pub const MAX_ENVELOPE_BYTES: usize = flybus::wire::MAX_ENVELOPE_BYTES; + +/// The `f64` a JSON number denotes, or `None` if it is not a finite exactly representable one. +pub fn finite_double(n: &Number) -> Option { + if let Some(u) = n.as_u64() { + return (u <= MAX_EXACT_INTEGER as u64).then_some(u as f64); + } + if let Some(i) = n.as_i64() { + return (i >= -MAX_EXACT_INTEGER).then_some(i as f64); + } + n.as_f64().filter(|v| v.is_finite()) +} + +/// `String(number)` for a finite double, the ECMAScript algorithm RFC 8785 requires. +fn number_to_string(value: f64) -> String { + if value == 0.0 { + // Covers -0.0, which `JSON.stringify` prints as "0". + return "0".to_owned(); + } + let mut buffer = ryu_js::Buffer::new(); + buffer.format(value).to_owned() +} + +/// Escapes one string the way `JSON.stringify` does. +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{08}' => out.push_str("\\b"), + '\u{09}' => out.push_str("\\t"), + '\u{0a}' => out.push_str("\\n"), + '\u{0c}' => out.push_str("\\f"), + '\u{0d}' => out.push_str("\\r"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// Sorts object keys by UTF-16 code unit, as RFC 8785 section 3.2.3 specifies. +fn utf16_key(key: &str) -> Vec { + key.encode_utf16().collect() +} + +/// The canonical JSON text of `value`. +pub fn canonicalize(value: &Value) -> Result { + let mut out = String::new(); + write_value(&mut out, value)?; + Ok(out) +} + +/// The canonical JSON bytes of `value`. +pub fn canonical_bytes(value: &Value) -> Result> { + canonicalize(value).map(String::into_bytes) +} + +fn write_value(out: &mut String, value: &Value) -> Result<()> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(true) => out.push_str("true"), + Value::Bool(false) => out.push_str("false"), + Value::Number(n) => { + let d = finite_double(n).ok_or_else(|| { + wire_err(format!( + "canonical JSON: {n} is not a finite number in the exact double range" + )) + })?; + out.push_str(&number_to_string(d)); + } + Value::String(s) => write_string(out, s), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_value(out, item)?; + } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_by_cached_key(|k| utf16_key(k)); + out.push('{'); + for (i, key) in keys.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(out, key); + out.push(':'); + write_value(out, &map[key.as_str()])?; + } + out.push('}'); + } + } + Ok(()) +} + +/// Lowercase hex SHA-256. +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes. +pub fn digest_of(value: &Value) -> Result { + canonical_bytes(value).map(|bytes| sha256_hex(&bytes)) +} + +/// Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, non-finite numbers and +/// trailing bytes are refused. The bus reader, reused so both layers agree byte for byte. +pub fn parse_strict(bytes: &[u8]) -> Result { + flybus::wire::parse_json_strict(bytes).map_err(|e| wire_err(e.0)) +} + +/// Refuses a domain payload that does not fit the bus envelope ceiling. +/// +/// The check is on canonical bytes, and the caller passes the overhead the surrounding +/// envelope adds, so a payload that only fits without its envelope still fails. +pub fn require_envelope_fit(value: &Value, envelope_overhead: usize) -> Result { + let len = canonicalize(value)?.len(); + let total = len + envelope_overhead; + if total > MAX_ENVELOPE_BYTES { + return err(format!( + "envelope: {total} bytes exceeds the {MAX_ENVELOPE_BYTES}-byte maximum" + )); + } + Ok(total) +} + +// --------------------------------------------------------------------------------------------- +// Operation keys and canonical bodies + +/// The keys that belong to the bus, never to a domain body (ipc-v1 section 5: the canonical +/// body "excludes changing bus callIds, deliveryIds and owner tokens"). +pub const BUS_ONLY_KEYS: &[&str] = &[ + "callId", + "deliveryId", + "ownerId", + "ownerIds", + "deliveryIds", + "requestDeliveryId", + "expectedIncarnation", + "serviceIncarnation", + "connectionId", + "topicSequence", + "subscriptionId", +]; + +/// Fails if any bus-only key appears anywhere in `value`. +pub fn reject_bus_identities(value: &Value) -> Result<()> { + match value { + Value::Object(map) => { + for (key, inner) in map { + if BUS_ONLY_KEYS.contains(&key.as_str()) { + return err(format!( + "canonical body: {key:?} is a bus identity and never part of a domain body" + )); + } + reject_bus_identities(inner)?; + } + Ok(()) + } + Value::Array(items) => { + for item in items { + reject_bus_identities(item)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation. +/// +/// There is at most one Prepare, Commit or Advance for one key (ipc-v1 section 5). The key +/// deliberately does not contain the requestId: a changed id for an existing key is CONFLICT, +/// which can only be detected if the key is the same. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OperationKey { + pub scope: Scope, + pub method: String, + pub worker_id: String, +} + +impl OperationKey { + pub fn new(scope: Scope, method: &str, worker_id: &str) -> Result { + let key = OperationKey { + scope, + method: method.to_owned(), + worker_id: worker_id.to_owned(), + }; + key.validate()?; + Ok(key) + } + + pub fn validate(&self) -> Result<()> { + use crate::scalar::DomainType; + self.scope.validate()?; + if !flybus::wire::is_method(&self.method) { + return err("OperationKey: method must be 1..=128 printable ASCII characters"); + } + if !crate::scalar::is_id(&self.worker_id) { + return err("OperationKey: workerId is not a valid id"); + } + Ok(()) + } + + pub fn to_json(&self) -> Value { + use crate::scalar::DomainType; + crate::scalar::obj(vec![ + ("scope", self.scope.to_json()), + ("method", self.method.clone().into()), + ("workerId", self.worker_id.clone().into()), + ]) + } + + /// The canonical digest of the key, for a deduplication table that stores digests. + pub fn digest(&self) -> Result { + digest_of(&self.to_json()) + } +} + +/// The canonical body of a domain operation: method, scope and validated params. +/// +/// Two calls of the same operation key whose body digests differ are CONFLICT; two calls with +/// the same digest are the same operation, whatever bus callId carried them. +pub fn canonical_body(method: &str, scope: Option<&Scope>, params: &Value) -> Result { + if !flybus::wire::is_method(method) { + return err("canonical body: method must be 1..=128 printable ASCII characters"); + } + if !params.is_object() { + return err("canonical body: params must be an object"); + } + reject_bus_identities(params)?; + Ok(crate::scalar::obj(vec![ + ("method", method.into()), + ("scope", Scope::nullable_to_json(scope)), + ("params", params.clone()), + ])) +} + +/// The canonical body digest of a domain operation. +pub fn body_digest(method: &str, scope: Option<&Scope>, params: &Value) -> Result { + digest_of(&canonical_body(method, scope, params)?) +} diff --git a/services/flysim/crates/fly-session-types/src/checkpoint.rs b/services/flysim/crates/fly-session-types/src/checkpoint.rs new file mode 100644 index 0000000..341f5c5 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/checkpoint.rs @@ -0,0 +1,368 @@ +//! `FLYSESS1`: the envelope layout of `docs/design/session-framework/checkpoint-envelope-v1.md`. +//! +//! This is the layout half of the specification, not the store: it lays out a header, a +//! canonical-JSON manifest, a payload table and the payload bytes, and it reads one back. +//! Writing generations, fsyncing and committing a manifest belong to the STATE-01 store slice. +//! `FLYSIM01` is a different format with a different magic and is not touched by any of this. + +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::canonical; +use crate::scalar::{Result, err, is_id}; + +/// Envelope magic. Eight ASCII bytes, distinct from `FLYSIM01`. +pub const MAGIC: &[u8; 8] = b"FLYSESS1"; +/// Footer magic, so a truncated file cannot look complete. +pub const FOOTER_MAGIC: &[u8; 8] = b"FLYSESSF"; +/// Envelope version, in the header and in the manifest. +pub const VERSION: u32 = 1; +/// Fixed header size in bytes. +pub const HEADER_BYTES: usize = 32; +/// One payload table entry: a 64-byte name field, offset, length and a 32-byte digest. +pub const TABLE_ENTRY_BYTES: usize = 112; +/// Payload name field width. +pub const NAME_BYTES: usize = 64; +/// Footer size in bytes: total length, whole-prefix digest and the footer magic. +pub const FOOTER_BYTES: usize = 48; +/// Payloads start on an eight-byte boundary. +pub const ALIGNMENT: u64 = 8; +/// Payloads per envelope. +pub const MAX_PAYLOADS: usize = 64; + +/// One payload's table entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayloadEntry { + /// An `Id`: the new envelope widens the historical letters-only chunk name deliberately, + /// which is why it is a new version and not an extension of `FLYSIM01`. + pub name: String, + pub offset: u64, + pub byte_length: u64, + /// SHA-256 of exactly `byte_length` bytes at `offset`. + pub digest: [u8; 32], +} + +/// A laid-out envelope: where everything is, before any bytes are written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layout { + pub manifest_offset: u64, + pub manifest_bytes: u32, + pub table_offset: u64, + pub entries: Vec, + pub footer_offset: u64, + pub total_bytes: u64, +} + +fn align_up(value: u64) -> u64 { + value.div_ceil(ALIGNMENT) * ALIGNMENT +} + +/// Lays out the envelope for one manifest and a list of `(name, bytes)` payloads. +pub fn layout(manifest: &Value, payloads: &[(String, Vec)]) -> Result { + if payloads.len() > MAX_PAYLOADS { + return err("checkpoint envelope: at most 64 payloads"); + } + crate::scalar::require_unique( + payloads.iter().map(|(name, _)| name.as_str()), + "checkpoint envelope: payload names", + )?; + for (name, _) in payloads { + if !is_id(name) { + return err(format!( + "checkpoint envelope: payload name {name:?} is not an Id" + )); + } + } + let manifest_text = canonical::canonicalize(manifest)?; + let manifest_bytes = u32::try_from(manifest_text.len()) + .map_err(|_| crate::scalar::wire_err("checkpoint envelope: manifest is too large"))?; + let manifest_offset = HEADER_BYTES as u64; + let table_offset = align_up(manifest_offset + u64::from(manifest_bytes)); + let mut offset = align_up(table_offset + (payloads.len() * TABLE_ENTRY_BYTES) as u64); + let mut entries = Vec::with_capacity(payloads.len()); + for (name, bytes) in payloads { + entries.push(PayloadEntry { + name: name.clone(), + offset, + byte_length: bytes.len() as u64, + digest: Sha256::digest(bytes).into(), + }); + offset = align_up(offset + bytes.len() as u64); + } + Ok(Layout { + manifest_offset, + manifest_bytes, + table_offset, + entries, + footer_offset: offset, + total_bytes: offset + FOOTER_BYTES as u64, + }) +} + +/// Writes one envelope: header, manifest, payload table, payloads, footer. +pub fn encode(manifest: &Value, payloads: &[(String, Vec)]) -> Result> { + let layout = layout(manifest, payloads)?; + let manifest_text = canonical::canonicalize(manifest)?; + let mut out = vec![0u8; layout.footer_offset as usize]; + out[0..8].copy_from_slice(MAGIC); + out[8..12].copy_from_slice(&VERSION.to_le_bytes()); + out[12..16].copy_from_slice(&(HEADER_BYTES as u32).to_le_bytes()); + out[16..20].copy_from_slice(&layout.manifest_bytes.to_le_bytes()); + out[20..24].copy_from_slice(&(payloads.len() as u32).to_le_bytes()); + out[24..28].copy_from_slice(&(layout.table_offset as u32).to_le_bytes()); + out[28..32].copy_from_slice(&0u32.to_le_bytes()); + let manifest_start = layout.manifest_offset as usize; + out[manifest_start..manifest_start + manifest_text.len()] + .copy_from_slice(manifest_text.as_bytes()); + for (index, entry) in layout.entries.iter().enumerate() { + let base = layout.table_offset as usize + index * TABLE_ENTRY_BYTES; + out[base..base + entry.name.len()].copy_from_slice(entry.name.as_bytes()); + let numbers = base + NAME_BYTES; + out[numbers..numbers + 8].copy_from_slice(&entry.offset.to_le_bytes()); + out[numbers + 8..numbers + 16].copy_from_slice(&entry.byte_length.to_le_bytes()); + out[numbers + 16..numbers + 48].copy_from_slice(&entry.digest); + } + for (entry, (_, bytes)) in layout.entries.iter().zip(payloads) { + let start = entry.offset as usize; + out[start..start + bytes.len()].copy_from_slice(bytes); + } + let digest: [u8; 32] = Sha256::digest(&out).into(); + out.extend_from_slice(&layout.total_bytes.to_le_bytes()); + out.extend_from_slice(&digest); + out.extend_from_slice(FOOTER_MAGIC); + Ok(out) +} + +/// A decoded envelope. +#[derive(Clone, Debug, PartialEq)] +pub struct Envelope { + pub manifest: Value, + pub payloads: Vec<(String, Vec)>, + pub layout: Layout, +} + +impl Envelope { + pub fn payload(&self, name: &str) -> Option<&[u8]> { + self.payloads + .iter() + .find(|(key, _)| key == name) + .map(|(_, bytes)| bytes.as_slice()) + } +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +fn u64_at(bytes: &[u8], offset: usize) -> u64 { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&bytes[offset..offset + 8]); + u64::from_le_bytes(buf) +} + +/// Reads and fully validates one envelope: magic, version, footer digest, table ordering, +/// alignment, bounds and every payload digest. +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_BYTES + FOOTER_BYTES { + return err("checkpoint envelope: shorter than a header plus a footer"); + } + if &bytes[0..8] != MAGIC { + return err("checkpoint envelope: wrong magic (FLYSIM01 is a different format)"); + } + if u32_at(bytes, 8) != VERSION { + return err("checkpoint envelope: unsupported version"); + } + if u32_at(bytes, 12) as usize != HEADER_BYTES { + return err("checkpoint envelope: headerBytes must be 32"); + } + if u32_at(bytes, 28) != 0 { + return err("checkpoint envelope: reserved header word must be zero"); + } + let manifest_bytes = u32_at(bytes, 16) as usize; + let payload_count = u32_at(bytes, 20) as usize; + let table_offset = u32_at(bytes, 24) as u64; + if payload_count > MAX_PAYLOADS { + return err("checkpoint envelope: at most 64 payloads"); + } + let footer_offset = bytes.len() - FOOTER_BYTES; + if &bytes[footer_offset + 40..] != FOOTER_MAGIC { + return err("checkpoint envelope: missing footer magic"); + } + if u64_at(bytes, footer_offset) != bytes.len() as u64 { + return err("checkpoint envelope: footer length does not match the file"); + } + let recorded = &bytes[footer_offset + 8..footer_offset + 40]; + let computed: [u8; 32] = Sha256::digest(&bytes[..footer_offset]).into(); + if recorded != computed { + return err("checkpoint envelope: footer digest does not match the contents"); + } + let manifest_start = HEADER_BYTES; + let manifest_end = manifest_start + manifest_bytes; + if manifest_end > footer_offset { + return err("checkpoint envelope: manifest runs past the payload area"); + } + let manifest = canonical::parse_strict(&bytes[manifest_start..manifest_end])?; + let canonical_manifest = canonical::canonicalize(&manifest)?; + if canonical_manifest.as_bytes() != &bytes[manifest_start..manifest_end] { + return err("checkpoint envelope: the manifest is not canonical JSON"); + } + if table_offset != align_up(manifest_end as u64) { + return err("checkpoint envelope: the payload table is not at its laid-out offset"); + } + let table_end = table_offset as usize + payload_count * TABLE_ENTRY_BYTES; + if table_end > footer_offset { + return err("checkpoint envelope: the payload table runs past the payload area"); + } + let mut entries = Vec::with_capacity(payload_count); + let mut payloads = Vec::with_capacity(payload_count); + let mut previous_end = align_up(table_end as u64); + for index in 0..payload_count { + let base = table_offset as usize + index * TABLE_ENTRY_BYTES; + let name_field = &bytes[base..base + NAME_BYTES]; + let length = name_field + .iter() + .position(|b| *b == 0) + .unwrap_or(NAME_BYTES); + if name_field[length..].iter().any(|b| *b != 0) { + return err("checkpoint envelope: a payload name has bytes after its terminator"); + } + let name = std::str::from_utf8(&name_field[..length]) + .map_err(|_| crate::scalar::wire_err("checkpoint envelope: payload name is not UTF-8"))? + .to_owned(); + if !is_id(&name) { + return err(format!( + "checkpoint envelope: payload name {name:?} is not an Id" + )); + } + let numbers = base + NAME_BYTES; + let offset = u64_at(bytes, numbers); + let byte_length = u64_at(bytes, numbers + 8); + let mut digest = [0u8; 32]; + digest.copy_from_slice(&bytes[numbers + 16..numbers + 48]); + if offset != previous_end { + return err(format!( + "checkpoint envelope: payload {name:?} starts at {offset}, not at its aligned {previous_end}" + )); + } + let end = offset + .checked_add(byte_length) + .ok_or_else(|| crate::scalar::wire_err("checkpoint envelope: payload overflows"))?; + if end > footer_offset as u64 { + return err(format!( + "checkpoint envelope: payload {name:?} runs past the payload area" + )); + } + let payload = bytes[offset as usize..end as usize].to_vec(); + let computed: [u8; 32] = Sha256::digest(&payload).into(); + if computed != digest { + return err(format!( + "checkpoint envelope: payload {name:?} fails its digest" + )); + } + previous_end = align_up(end); + entries.push(PayloadEntry { + name: name.clone(), + offset, + byte_length, + digest, + }); + payloads.push((name, payload)); + } + crate::scalar::require_unique( + entries.iter().map(|e| e.name.as_str()), + "checkpoint envelope: payload names", + )?; + if previous_end != footer_offset as u64 { + return err("checkpoint envelope: padding between the last payload and the footer"); + } + Ok(Envelope { + manifest, + layout: Layout { + manifest_offset: manifest_start as u64, + manifest_bytes: manifest_bytes as u32, + table_offset, + entries, + footer_offset: footer_offset as u64, + total_bytes: bytes.len() as u64, + }, + payloads, + }) +} + +/// The manifest fields state-media-v1 section 4 requires, checked as a set: a manifest that +/// omits one of them is not a complete checkpoint. +pub const REQUIRED_MANIFEST_FIELDS: &[&str] = &[ + "envelopeVersion", + "checkpointId", + "sourceScope", + "episodeId", + "worldTime", + "schedulerId", + "compositionDigest", + "portMap", + "compatibility", + "agents", + "coordinator", + "payloads", +]; + +/// Checks the manifest's required field set and that its payload table mirrors the envelope's. +pub fn validate_manifest(envelope: &Envelope) -> Result<()> { + let map = envelope + .manifest + .as_object() + .ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: must be an object"))?; + for field in REQUIRED_MANIFEST_FIELDS { + if !map.contains_key(*field) { + return err(format!("checkpoint manifest: missing {field:?}")); + } + } + if map.get("envelopeVersion").and_then(Value::as_u64) != Some(u64::from(VERSION)) { + return err("checkpoint manifest: envelopeVersion must be 1"); + } + let listed = map + .get("payloads") + .and_then(Value::as_array) + .ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: payloads must be an array"))?; + if listed.len() != envelope.layout.entries.len() { + return err("checkpoint manifest: payloads does not match the payload table"); + } + for (declared, entry) in listed.iter().zip(&envelope.layout.entries) { + let name = declared.get("name").and_then(Value::as_str); + let length = declared + .get("byteLength") + .and_then(Value::as_str) + .and_then(crate::scalar::parse_u64); + let digest = declared.get("digest").and_then(Value::as_str); + if name != Some(entry.name.as_str()) { + return err("checkpoint manifest: payload name does not match the table"); + } + if length != Some(entry.byte_length) { + return err(format!( + "checkpoint manifest: payload {:?} byteLength does not match the table", + entry.name + )); + } + if digest != Some(hex(&entry.digest).as_str()) { + return err(format!( + "checkpoint manifest: payload {:?} digest does not match the table", + entry.name + )); + } + } + Ok(()) +} + +/// Lowercase hex of a raw digest, the form the manifest records. +pub fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} diff --git a/services/flysim/crates/fly-session-types/src/fixtures.rs b/services/flysim/crates/fly-session-types/src/fixtures.rs new file mode 100644 index 0000000..661859e --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/fixtures.rs @@ -0,0 +1,108 @@ +//! Loading the crate's `fixtures/` directory. +//! +//! The same files are read by the Rust tests and by `packages/session-types`, so a case only +//! has to be written once to hold both languages to it. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{Result, err, wire_err}; + +/// The crate's `fixtures/` directory. +pub fn dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures") +} + +/// Reads one fixture file, parsed strictly. +pub fn load(name: &str) -> Result { + let path = dir().join(name); + let bytes = + std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display())))?; + canonical::parse_strict(&bytes) +} + +/// Reads one fixture file as raw bytes, for the cases that are deliberately not valid JSON. +pub fn load_bytes(name: &str) -> Result> { + let path = dir().join(name); + std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display()))) +} + +/// The `cases` array of a fixture file. +pub fn cases(file: &Value) -> Result<&Vec> { + match file.get("cases").and_then(Value::as_array) { + Some(cases) if !cases.is_empty() => Ok(cases), + _ => err("fixture: cases must be a nonempty array"), + } +} + +/// A string field of one case. +pub fn field<'a>(case: &'a Value, key: &str) -> Result<&'a str> { + case.get(key) + .and_then(Value::as_str) + .ok_or_else(|| wire_err(format!("fixture case: missing string field {key:?}"))) +} + +/// Decodes the `base64` field of a case that carries raw bytes. +pub fn base64(case: &Value, key: &str) -> Result> { + decode_base64(field(case, key)?) +} + +/// Standard base64 with padding. Small and local: the crate has no base64 dependency and the +/// fixtures only carry a few hundred bytes. +pub fn decode_base64(text: &str) -> Result> { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = text.as_bytes(); + if !bytes.len().is_multiple_of(4) { + return err("base64: length must be a multiple of 4"); + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for quad in bytes.chunks_exact(4) { + let mut buffer = 0u32; + let mut keep = 3; + for (index, byte) in quad.iter().enumerate() { + let value = if *byte == b'=' { + if index < 2 { + return err("base64: misplaced padding"); + } + keep -= 1; + 0 + } else { + ALPHABET + .iter() + .position(|c| c == byte) + .ok_or_else(|| wire_err("base64: invalid character"))? as u32 + }; + buffer = (buffer << 6) | value; + } + let triple = buffer.to_be_bytes(); + out.extend_from_slice(&triple[1..1 + keep]); + } + Ok(out) +} + +/// Standard base64 with padding, for generating fixtures. +pub fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let mut buffer = [0u8; 3]; + buffer[..chunk.len()].copy_from_slice(chunk); + let value = u32::from_be_bytes([0, buffer[0], buffer[1], buffer[2]]); + let indexes = [ + (value >> 18) & 0x3f, + (value >> 12) & 0x3f, + (value >> 6) & 0x3f, + value & 0x3f, + ]; + for (position, index) in indexes.iter().enumerate() { + if position <= chunk.len() { + out.push(ALPHABET[*index as usize] as char); + } else { + out.push('='); + } + } + } + out +} diff --git a/services/flysim/crates/fly-session-types/src/lib.rs b/services/flysim/crates/fly-session-types/src/lib.rs new file mode 100644 index 0000000..bacac0f --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/lib.rs @@ -0,0 +1,56 @@ +//! `fly-session-types`: the executable schemas of the session framework (CONTRACT-01). +//! +//! What this crate is: +//! +//! - the domain scalars of [ipc-v1] section 2 ([`scalar`]), reusing the bus's `Id`, `U64` and +//! `Digest` encodings rather than restating them; +//! the domain request/reply envelope and error codes of sections 3 and 7 ([`rpc`]); +//! - the closed enums and method payloads of [workers-v1] ([`workers`]), the native media +//! and State.* payloads of [state-media-v1] ([`media`]), and the publication types of +//! [publishing-v1] ([`publishing`]); +//! - canonical JSON (RFC 8785), canonical digests, the operation key and the canonical body +//! rules of ipc-v1 section 5 ([`canonical`]); +//! - the documented canonical schema set and `contractDigest` ([`schema`]); +//! - the trace format of [step-v1] section 8, with behaviour separated from operational +//! metadata and a comparator over behaviour alone ([`trace`]); +//! - `seed-derivation-v1` ([`seed`]) and the `FLYSESS1` checkpoint envelope layout +//! ([`checkpoint`]), the two specifications CONTRACT-01 has to settle before the real-agent +//! and store slices. +//! +//! What it is not: a transport, a worker, a coordinator or a store. It holds no Game Boy FFI, +//! no Melee parser and no console-specific state, and it never reaches the network. +//! +//! Every type implements [`scalar::DomainType`]: `from_json` reads and validates, `to_json` +//! writes the canonical shape, and `validate` re-checks the rules that span fields. Reading +//! refuses unknown fields, so a payload with a misspelled required field fails instead of +//! silently defaulting. +//! +//! [ipc-v1]: ../../../../docs/design/session-framework/ipc-v1.md +//! [workers-v1]: ../../../../docs/design/session-framework/workers-v1.md +//! [state-media-v1]: ../../../../docs/design/session-framework/state-media-v1.md +//! [publishing-v1]: ../../../../docs/design/session-framework/publishing-v1.md +//! [step-v1]: ../../../../docs/design/session-framework/step-v1.md + +pub mod canonical; +pub mod checkpoint; +pub mod fixtures; +pub mod media; +pub mod publishing; +pub mod rpc; +pub mod scalar; +pub mod schema; +pub mod seed; +pub mod trace; +pub mod workers; + +pub use canonical::{OperationKey, body_digest, canonicalize, digest_of}; +pub use scalar::{ + ArtifactIdentity, BusCallId, DomainRequestId, DomainType, OwnerKind, OwnerToken, RationalNs, + SchemaRef, Scope, TypedValue, +}; +pub use schema::contract_digest; +pub use trace::{TraceBehaviour, TraceOperational, TransitionTrace}; + +/// The bus `ArtifactRef` these contracts reference. Re-exported so a consumer does not have +/// to decide whether the domain has its own copy: it does not. +pub use flybus::wire::ArtifactRef; diff --git a/services/flysim/crates/fly-session-types/src/media.rs b/services/flysim/crates/fly-session-types/src/media.rs new file mode 100644 index 0000000..5f2a1d8 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/media.rs @@ -0,0 +1,716 @@ +//! Native observation media (state-media-v1 section 2) and the State.* payloads (section 5). +//! +//! Descriptors carry the shape; refs carry one produced object. Both are validated against +//! the descriptor, because a ref on its own cannot know its own row stride: use +//! [`ViewRef::validate_against`] and [`AudioRef::validate_against`] wherever the descriptor +//! is in hand. + +use flybus::wire::{ArtifactRef, Fields}; + +use serde_json::Value; + +use crate::scalar::{ + DomainType, RationalNs, Result, Scope, constant, err, finite_in, is_digest, is_id, list, obj, + require_unique, u64_json, +}; + +/// Max views per sensory input (workers-v1 section 1). The same bound applies to a +/// descriptor's view list and to an observation's view lists: a descriptor that declared more +/// views than one sensory input can carry could not be satisfied. +pub const MAX_VIEWS: usize = 8; +/// View dimensions are integers 1..=4096 (state-media-v1 section 2). +pub const MAX_VIEW_DIMENSION: u64 = 4096; +/// Pixel aspect numerator/denominator are positive integers <=65535. +pub const MAX_PIXEL_ASPECT: u64 = 65_535; +/// observationDelaySteps is an integer 0..=8. +pub const MAX_OBSERVATION_DELAY_STEPS: u64 = 8; +/// sampleFrames is 0..=192000 per chunk; sampleRate is 8000..=192000. +pub const MAX_SAMPLE_FRAMES: u64 = 192_000; +/// Audio streams per descriptor. Not a stated bound: chosen so an envelope cannot be filled +/// with descriptors, and recorded in the schema set so it cannot drift silently. +pub const MAX_AUDIO_STREAMS: usize = 8; + +/// `ViewDescriptor`: the fixed shape of one native view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewDescriptor { + pub view_id: String, + pub width: u64, + pub height: u64, + pub row_stride: u64, + pub pixel_aspect_numerator: u64, + pub pixel_aspect_denominator: u64, + pub observation_delay_steps: u64, +} + +impl ViewDescriptor { + /// The exact byte length of one frame of this view. + pub fn frame_bytes(&self) -> u64 { + self.row_stride * self.height + } + + /// The producing boundary a required sensory view must have at `boundary` + /// (state-media-v1 section 2): `max(0, boundary - observationDelaySteps)`. + pub fn required_produced_step(&self, boundary: u64) -> u64 { + boundary.saturating_sub(self.observation_delay_steps) + } +} + +impl DomainType for ViewDescriptor { + const TYPE_NAME: &'static str = "ViewDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ViewDescriptor")?; + let view_id = f.id("viewId")?; + let width = f.int("width", 1, MAX_VIEW_DIMENSION)?; + let height = f.int("height", 1, MAX_VIEW_DIMENSION)?; + constant(&mut f, "format", "rgba8")?; + let row_stride = f.int("rowStride", 1, MAX_VIEW_DIMENSION * 4)?; + let aspect = f.value("pixelAspect")?; + let (pixel_aspect_numerator, pixel_aspect_denominator) = { + let mut a = Fields::new(aspect, "ViewDescriptor.pixelAspect")?; + let n = a.int("numerator", 1, MAX_PIXEL_ASPECT)?; + let d = a.int("denominator", 1, MAX_PIXEL_ASPECT)?; + a.finish()?; + (n, d) + }; + let observation_delay_steps = + f.int("observationDelaySteps", 0, MAX_OBSERVATION_DELAY_STEPS)?; + f.finish()?; + let d = ViewDescriptor { + view_id, + width, + height, + row_stride, + pixel_aspect_numerator, + pixel_aspect_denominator, + observation_delay_steps, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("viewId", self.view_id.clone().into()), + ("width", Value::from(self.width)), + ("height", Value::from(self.height)), + ("format", "rgba8".into()), + ("rowStride", Value::from(self.row_stride)), + ( + "pixelAspect", + obj(vec![ + ("numerator", Value::from(self.pixel_aspect_numerator)), + ("denominator", Value::from(self.pixel_aspect_denominator)), + ]), + ), + ( + "observationDelaySteps", + Value::from(self.observation_delay_steps), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.view_id) { + return err("ViewDescriptor: viewId is not a valid id"); + } + if !(1..=MAX_VIEW_DIMENSION).contains(&self.width) + || !(1..=MAX_VIEW_DIMENSION).contains(&self.height) + { + return err("ViewDescriptor: width and height must be integers 1..=4096"); + } + if self.row_stride != self.width * 4 { + return err( + "ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)", + ); + } + if !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_numerator) + || !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_denominator) + { + return err("ViewDescriptor: pixelAspect parts must be positive integers <=65535"); + } + if self.observation_delay_steps > MAX_OBSERVATION_DELAY_STEPS { + return err("ViewDescriptor: observationDelaySteps must be 0..=8"); + } + Ok(()) + } +} + +/// `ViewRef`: one produced frame of one view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewRef { + pub view_id: String, + pub produced_step: u64, + pub pixels: ArtifactRef, +} + +impl ViewRef { + /// Byte shape and producing boundary against the descriptor that declared this view. + /// + /// `boundary` is the observation's boundary; a required sensory view must have been + /// produced at exactly `max(0, boundary - observationDelaySteps)`. + pub fn validate_against( + &self, + descriptor: &ViewDescriptor, + boundary: Option, + ) -> Result<()> { + if self.view_id != descriptor.view_id { + return err(format!( + "ViewRef: viewId {:?} does not match descriptor {:?}", + self.view_id, descriptor.view_id + )); + } + if self.pixels.byte_length != descriptor.frame_bytes() { + return err(format!( + "ViewRef {}: artifact is {} bytes, rowStride x height is {}", + self.view_id, + self.pixels.byte_length, + descriptor.frame_bytes() + )); + } + if let Some(boundary) = boundary { + let expected = descriptor.required_produced_step(boundary); + if self.produced_step != expected { + return err(format!( + "ViewRef {}: producedStep {} must be max(0, {boundary} - {}) = {expected}", + self.view_id, self.produced_step, descriptor.observation_delay_steps + )); + } + } + Ok(()) + } +} + +impl DomainType for ViewRef { + const TYPE_NAME: &'static str = "ViewRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ViewRef")?; + let view_id = f.id("viewId")?; + let produced_step = f.u64_string("producedStep")?; + let pixels = ArtifactRef::from_json(f.value("pixels")?)?; + f.finish()?; + let r = ViewRef { + view_id, + produced_step, + pixels, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("viewId", self.view_id.clone().into()), + ("producedStep", u64_json(self.produced_step)), + ("pixels", self.pixels.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.view_id) { + return err("ViewRef: viewId is not a valid id"); + } + if self.pixels.byte_length == 0 { + return err("ViewRef: pixels must have a positive byte length"); + } + Ok(()) + } +} + +/// `AudioDescriptor`: one native audio stream's shape. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioDescriptor { + pub stream_id: String, + pub sample_rate: u64, + pub channels: u64, +} + +impl AudioDescriptor { + /// The exact byte length of `frames` interleaved f32 frames. + pub fn chunk_bytes(&self, frames: u64) -> u64 { + frames * self.channels * 4 + } +} + +impl DomainType for AudioDescriptor { + const TYPE_NAME: &'static str = "AudioDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AudioDescriptor")?; + let stream_id = f.id("streamId")?; + let sample_rate = f.int("sampleRate", 8_000, 192_000)?; + let channels = f.int("channels", 1, 8)?; + constant(&mut f, "format", "f32le-interleaved")?; + f.finish()?; + let d = AudioDescriptor { + stream_id, + sample_rate, + channels, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("streamId", self.stream_id.clone().into()), + ("sampleRate", Value::from(self.sample_rate)), + ("channels", Value::from(self.channels)), + ("format", "f32le-interleaved".into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.stream_id) { + return err("AudioDescriptor: streamId is not a valid id"); + } + if !(8_000..=192_000).contains(&self.sample_rate) { + return err("AudioDescriptor: sampleRate must be an integer 8000..=192000"); + } + if !(1..=8).contains(&self.channels) { + return err("AudioDescriptor: channels must be an integer 1..=8"); + } + Ok(()) + } +} + +/// `AudioRef`: one produced chunk of one audio stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioRef { + pub stream_id: String, + pub first_sample: u64, + pub sample_frames: u64, + pub samples: ArtifactRef, + pub discontinuity: bool, +} + +impl AudioRef { + /// Byte shape against the descriptor that declared this stream. + pub fn validate_against(&self, descriptor: &AudioDescriptor) -> Result<()> { + if self.stream_id != descriptor.stream_id { + return err(format!( + "AudioRef: streamId {:?} does not match descriptor {:?}", + self.stream_id, descriptor.stream_id + )); + } + let expected = descriptor.chunk_bytes(self.sample_frames); + if self.samples.byte_length != expected { + return err(format!( + "AudioRef {}: artifact is {} bytes, sampleFrames x channels x 4 is {expected}", + self.stream_id, self.samples.byte_length + )); + } + Ok(()) + } + + /// Within an epoch chunks cannot overlap or go backwards (state-media-v1 section 2). + pub fn follows(&self, previous: &AudioRef) -> Result<()> { + if self.stream_id != previous.stream_id { + return err("AudioRef: chunks of different streams are not ordered against each other"); + } + let expected = previous.first_sample + previous.sample_frames; + if self.first_sample < expected { + return err(format!( + "AudioRef {}: firstSample {} overlaps the previous chunk, which ends at {expected}", + self.stream_id, self.first_sample + )); + } + Ok(()) + } +} + +impl DomainType for AudioRef { + const TYPE_NAME: &'static str = "AudioRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AudioRef")?; + let stream_id = f.id("streamId")?; + let first_sample = f.u64_string("firstSample")?; + let sample_frames = f.int("sampleFrames", 0, MAX_SAMPLE_FRAMES)?; + let samples = ArtifactRef::from_json(f.value("samples")?)?; + let discontinuity = f.boolean("discontinuity")?; + f.finish()?; + let r = AudioRef { + stream_id, + first_sample, + sample_frames, + samples, + discontinuity, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("streamId", self.stream_id.clone().into()), + ("firstSample", u64_json(self.first_sample)), + ("sampleFrames", Value::from(self.sample_frames)), + ("samples", self.samples.to_json()), + ("discontinuity", Value::Bool(self.discontinuity)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.stream_id) { + return err("AudioRef: streamId is not a valid id"); + } + if self.sample_frames > MAX_SAMPLE_FRAMES { + return err("AudioRef: sampleFrames must be an integer 0..=192000"); + } + if self.first_sample.checked_add(self.sample_frames).is_none() { + return err("AudioRef: firstSample + sampleFrames overflows U64"); + } + Ok(()) + } +} + +/// Reads a bounded, unique-by-`viewId` list of view refs. +pub fn view_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let views = list(f, key, 0, MAX_VIEWS, ViewRef::from_json)?; + require_unique(views.iter().map(|v| v.view_id.as_str()), key)?; + Ok(views) +} + +/// Reads a bounded, unique-by-`streamId` list of audio refs. +pub fn audio_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let audio = list(f, key, 0, MAX_AUDIO_STREAMS, AudioRef::from_json)?; + require_unique(audio.iter().map(|a| a.stream_id.as_str()), key)?; + Ok(audio) +} + +// --------------------------------------------------------------------------------------------- +// State.* payloads (state-media-v1 section 5) + +/// A checkpoint payload artifact: the digest is mandatory on checkpoint payloads +/// (state-media-v1 section 1). +fn checkpoint_payload(f: &mut Fields<'_>, key: &'static str) -> Result { + let reference = ArtifactRef::from_json(f.value(key)?)?; + match &reference.digest { + Some(d) if is_digest(d) => Ok(reference), + _ => err(format!( + "{key}: a checkpoint payload must carry a content digest" + )), + } +} + +/// `State.Capture` params. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaptureParams { + pub checkpoint_id: String, +} + +impl DomainType for CaptureParams { + const TYPE_NAME: &'static str = "CaptureParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CaptureParams")?; + let checkpoint_id = f.id("checkpointId")?; + f.finish()?; + let p = CaptureParams { checkpoint_id }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("checkpointId", self.checkpoint_id.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("CaptureParams: checkpointId is not a valid id"); + } + Ok(()) + } +} + +/// `State.Capture` result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaptureResult { + pub checkpoint_id: String, + pub boundary: u64, + pub compatibility_digest: String, + pub payload: ArtifactRef, +} + +impl DomainType for CaptureResult { + const TYPE_NAME: &'static str = "CaptureResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CaptureResult")?; + let checkpoint_id = f.id("checkpointId")?; + let boundary = f.u64_string("boundary")?; + let compatibility_digest = f.string("compatibilityDigest")?.to_owned(); + let payload = checkpoint_payload(&mut f, "payload")?; + f.finish()?; + let r = CaptureResult { + checkpoint_id, + boundary, + compatibility_digest, + payload, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("boundary", u64_json(self.boundary)), + ( + "compatibilityDigest", + self.compatibility_digest.clone().into(), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("CaptureResult: checkpointId is not a valid id"); + } + if !is_digest(&self.compatibility_digest) { + return err("CaptureResult: compatibilityDigest must be 64 lowercase hex digits"); + } + match &self.payload.digest { + Some(d) if is_digest(d) => Ok(()), + _ => err("CaptureResult: a checkpoint payload must carry a content digest"), + } + } +} + +/// `State.StageRestore` params. The scope is the source boundary, under a proposed new epoch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StageRestoreParams { + pub checkpoint_id: String, + pub source_scope: Scope, + pub compatibility_digest: String, + pub payload: ArtifactRef, +} + +impl DomainType for StageRestoreParams { + const TYPE_NAME: &'static str = "StageRestoreParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StageRestoreParams")?; + let checkpoint_id = f.id("checkpointId")?; + let source_scope = Scope::from_json(f.value("sourceScope")?)?; + let compatibility_digest = f.string("compatibilityDigest")?.to_owned(); + let payload = checkpoint_payload(&mut f, "payload")?; + f.finish()?; + let p = StageRestoreParams { + checkpoint_id, + source_scope, + compatibility_digest, + payload, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("sourceScope", self.source_scope.to_json()), + ( + "compatibilityDigest", + self.compatibility_digest.clone().into(), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("StageRestoreParams: checkpointId is not a valid id"); + } + self.source_scope.validate()?; + if !is_digest(&self.compatibility_digest) { + return err("StageRestoreParams: compatibilityDigest must be 64 lowercase hex digits"); + } + match &self.payload.digest { + Some(d) if is_digest(d) => Ok(()), + _ => err("StageRestoreParams: a checkpoint payload must carry a content digest"), + } + } +} + +/// `State.StageRestore` result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StageRestoreResult { + pub checkpoint_id: String, + pub restore_token: String, +} + +impl DomainType for StageRestoreResult { + const TYPE_NAME: &'static str = "StageRestoreResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StageRestoreResult")?; + let checkpoint_id = f.id("checkpointId")?; + let restore_token = f.id("restoreToken")?; + f.finish()?; + let r = StageRestoreResult { + checkpoint_id, + restore_token, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("restoreToken", self.restore_token.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) || !is_id(&self.restore_token) { + return err("StageRestoreResult: checkpointId and restoreToken must be valid ids"); + } + Ok(()) + } +} + +/// `State.ActivateRestore` params. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActivateRestoreParams { + pub restore_token: String, +} + +impl DomainType for ActivateRestoreParams { + const TYPE_NAME: &'static str = "ActivateRestoreParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ActivateRestoreParams")?; + let restore_token = f.id("restoreToken")?; + f.finish()?; + let p = ActivateRestoreParams { restore_token }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("restoreToken", self.restore_token.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.restore_token) { + return err("ActivateRestoreParams: restoreToken is not a valid id"); + } + Ok(()) + } +} + +/// `State.ActivateRestore` result. The observation is required from an environment and null +/// from an agent (state-media-v1 section 5); which one applies is the caller's role, so the +/// role-specific check is [`ActivateRestoreResult::validate_for_role`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ActivateRestoreResult { + pub committed_step: u64, + pub checkpoint_id: String, + pub observation: Option, +} + +impl ActivateRestoreResult { + pub fn validate_for_role(&self, role: crate::workers::Role) -> Result<()> { + self.validate()?; + match (role, &self.observation) { + (crate::workers::Role::Environment, None) => { + err("ActivateRestoreResult: an environment must return its restored observation") + } + (crate::workers::Role::Agent, Some(_)) => { + err("ActivateRestoreResult: an agent returns a null observation") + } + _ => Ok(()), + } + } +} + +impl DomainType for ActivateRestoreResult { + const TYPE_NAME: &'static str = "ActivateRestoreResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ActivateRestoreResult")?; + let committed_step = f.u64_string("committedStep")?; + let checkpoint_id = f.id("checkpointId")?; + let observation = match f.value("observation")? { + Value::Null => None, + v => Some(crate::workers::WorldObservation::from_json(v)?), + }; + f.finish()?; + let r = ActivateRestoreResult { + committed_step, + checkpoint_id, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("committedStep", u64_json(self.committed_step)), + ("checkpointId", self.checkpoint_id.clone().into()), + ( + "observation", + self.observation + .as_ref() + .map_or(Value::Null, |o| o.to_json()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("ActivateRestoreResult: checkpointId is not a valid id"); + } + if let Some(observation) = &self.observation { + observation.validate()?; + if observation.boundary != self.committed_step { + return err( + "ActivateRestoreResult: the observation boundary must be the committed step", + ); + } + } + Ok(()) + } +} + +/// The pixel aspect of a view as a rational, for presentation. +pub fn pixel_aspect(descriptor: &ViewDescriptor) -> Result { + RationalNs::reduced( + u128::from(descriptor.pixel_aspect_numerator), + u128::from(descriptor.pixel_aspect_denominator), + ) +} + +/// Audio presentation timestamp, `firstSample / sampleRate` seconds, as a checked rational. +pub fn audio_pts(reference: &AudioRef, descriptor: &AudioDescriptor) -> Result { + RationalNs::reduced( + u128::from(reference.first_sample), + u128::from(descriptor.sample_rate), + ) +} + +/// Samples must be finite f32 (state-media-v1 section 2). The bytes live in an artifact, so +/// this is the check a reader runs over a mapped chunk. +pub fn require_finite_samples(bytes: &[u8]) -> Result<()> { + if !bytes.len().is_multiple_of(4) { + return err("audio chunk: length must be a multiple of 4"); + } + for (index, chunk) in bytes.chunks_exact(4).enumerate() { + let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + if !sample.is_finite() { + return err(format!("audio chunk: sample {index} is not finite")); + } + } + Ok(()) +} + +/// A unit-range helper for presentation code that needs the neutral-in-range rule. +pub fn require_unit(f: &mut Fields<'_>, key: &'static str) -> Result { + finite_in(f, key, 0.0, 1.0) +} diff --git a/services/flysim/crates/fly-session-types/src/publishing.rs b/services/flysim/crates/fly-session-types/src/publishing.rs new file mode 100644 index 0000000..b90d8e5 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/publishing.rs @@ -0,0 +1,450 @@ +//! The publication types of publishing-v1 section 3. +//! +//! A descriptor changes rarely and a snapshot changes every boundary; both are published on +//! the same bus, and a snapshot names the descriptor revision it was shaped by. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::media::{AudioRef, MAX_VIEWS, ViewRef, audio_list, view_list}; +use crate::scalar::{ + DomainType, RationalNs, Result, SchemaRef, Scope, TypedValue, constant, err, id_list, + is_digest, is_id, list, obj, require_unique, u64_json, +}; +use crate::workers::{ + AgentTelemetry, AssetRef, EnvironmentDescriptor, MAX_AGENTS, MAX_RATE_ROLES, PortControl, +}; + +/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set. +pub const MAX_SUPPORTED_STIMULI: usize = 64; +/// Installed assets in one descriptor. Not a stated bound; recorded in the schema set. +pub const MAX_ASSETS: usize = 64; +/// Scoped event ids in one snapshot. Not a stated bound; recorded in the schema set. +pub const MAX_SNAPSHOT_EVENTS: usize = 64; + +/// One agent's place in the composition. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentDescriptor { + pub agent_id: String, + pub port_id: String, + pub profile_digest: String, + pub dataset_digest: String, + pub index_digest: String, + pub neuron_count: u64, + pub rate_roles: Vec, + pub supported_stimuli: Vec, +} + +/// `SessionDescriptor`: the framework shape of one running session. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionDescriptor { + pub session_id: String, + pub revision: u64, + pub composition_digest: String, + pub environment: EnvironmentDescriptor, + pub task_schema: SchemaRef, + pub agents: Vec, + pub assets: Vec, +} + +impl DomainType for SessionDescriptor { + const TYPE_NAME: &'static str = "SessionDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionDescriptor")?; + let session_id = f.id("sessionId")?; + let revision = f.u64_string("revision")?; + let composition_digest = f.string("compositionDigest")?.to_owned(); + constant(&mut f, "schedulerId", "lockstep-v1")?; + let environment = EnvironmentDescriptor::from_json(f.value("environment")?)?; + let task_schema = SchemaRef::from_json(f.value("taskSchema")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "SessionDescriptor.agents")?; + let agent_id = a.id("agentId")?; + let port_id = a.id("portId")?; + let profile_digest = a.string("profileDigest")?.to_owned(); + let dataset_digest = a.string("datasetDigest")?.to_owned(); + let index_digest = a.string("indexDigest")?.to_owned(); + let neuron_count = a.u64_string("neuronCount")?; + let rate_roles = id_list(&mut a, "rateRoles", 0, MAX_RATE_ROLES)?; + let supported_stimuli = id_list(&mut a, "supportedStimuli", 0, MAX_SUPPORTED_STIMULI)?; + a.finish()?; + Ok(AgentDescriptor { + agent_id, + port_id, + profile_digest, + dataset_digest, + index_digest, + neuron_count, + rate_roles, + supported_stimuli, + }) + })?; + let assets = list(&mut f, "assets", 0, MAX_ASSETS, AssetRef::from_json)?; + f.finish()?; + let d = SessionDescriptor { + session_id, + revision, + composition_digest, + environment, + task_schema, + agents, + assets, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("revision", u64_json(self.revision)), + ("compositionDigest", self.composition_digest.clone().into()), + ("schedulerId", "lockstep-v1".into()), + ("environment", self.environment.to_json()), + ("taskSchema", self.task_schema.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("portId", a.port_id.clone().into()), + ("profileDigest", a.profile_digest.clone().into()), + ("datasetDigest", a.dataset_digest.clone().into()), + ("indexDigest", a.index_digest.clone().into()), + ("neuronCount", u64_json(a.neuron_count)), + ( + "rateRoles", + Value::Array( + a.rate_roles.iter().map(|r| r.clone().into()).collect(), + ), + ), + ( + "supportedStimuli", + Value::Array( + a.supported_stimuli + .iter() + .map(|s| s.clone().into()) + .collect(), + ), + ), + ]) + }) + .collect(), + ), + ), + ( + "assets", + Value::Array(self.assets.iter().map(AssetRef::to_json).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) { + return err("SessionDescriptor: sessionId is not a valid id"); + } + if !is_digest(&self.composition_digest) { + return err("SessionDescriptor: compositionDigest must be 64 lowercase hex digits"); + } + self.environment.validate()?; + self.task_schema.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("SessionDescriptor: 1..=4 agents in the first composition"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "SessionDescriptor.agents agentId", + )?; + require_unique( + self.agents.iter().map(|a| a.port_id.as_str()), + "SessionDescriptor.agents portId", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) || !is_id(&agent.port_id) { + return err("SessionDescriptor: agentId and portId must be valid ids"); + } + for (what, digest) in [ + ("profileDigest", &agent.profile_digest), + ("datasetDigest", &agent.dataset_digest), + ("indexDigest", &agent.index_digest), + ] { + if !is_digest(digest) { + return err(format!( + "SessionDescriptor: agent {what} must be 64 lowercase hex digits" + )); + } + } + if agent.rate_roles.len() > MAX_RATE_ROLES { + return err("SessionDescriptor: at most 64 rate roles per agent"); + } + require_unique( + agent.rate_roles.iter().map(String::as_str), + "SessionDescriptor.agents rateRoles", + )?; + require_unique( + agent.supported_stimuli.iter().map(String::as_str), + "SessionDescriptor.agents supportedStimuli", + )?; + if self.environment.port(&agent.port_id).is_none() { + return err(format!( + "SessionDescriptor: agent {:?} is bound to port {:?}, which the environment does not declare", + agent.agent_id, agent.port_id + )); + } + } + require_unique( + self.assets.iter().map(|a| a.id.as_str()), + "SessionDescriptor.assets", + )?; + for asset in &self.assets { + asset.validate()?; + } + Ok(()) + } +} + +/// One agent's committed values in a snapshot. +#[derive(Clone, Debug, PartialEq)] +pub struct SnapshotAgent { + pub agent_id: String, + pub telemetry: AgentTelemetry, + pub selected_decision: Option, + pub applied_controls: Option, +} + +/// `CommittedSnapshot`: the values of one committed boundary. +#[derive(Clone, Debug, PartialEq)] +pub struct CommittedSnapshot { + pub descriptor_revision: u64, + pub publisher_incarnation: String, + pub scope: Scope, + pub episode_id: String, + pub sequence: u64, + pub world_time: RationalNs, + pub agents: Vec, + pub progress: TypedValue, + pub views: Vec, + pub audio: Vec, + pub event_ids: Vec, +} + +impl CommittedSnapshot { + /// Descriptor agreement: the revision, the agent set and the port each control names. + pub fn validate_against(&self, descriptor: &SessionDescriptor) -> Result<()> { + self.validate()?; + if self.descriptor_revision != descriptor.revision { + return err("CommittedSnapshot: descriptorRevision does not match the descriptor"); + } + if self.scope.session_id != descriptor.session_id { + return err("CommittedSnapshot: sessionId does not match the descriptor"); + } + for agent in &self.agents { + let declared = descriptor + .agents + .iter() + .find(|a| a.agent_id == agent.agent_id) + .ok_or_else(|| { + crate::scalar::wire_err(format!( + "CommittedSnapshot: agent {:?} is not in the descriptor", + agent.agent_id + )) + })?; + agent + .telemetry + .validate_against_roles(&declared.rate_roles)?; + if let Some(controls) = &agent.applied_controls { + if controls.port_id != declared.port_id { + return err(format!( + "CommittedSnapshot: agent {:?} controls port {:?}, not its assigned {:?}", + agent.agent_id, controls.port_id, declared.port_id + )); + } + let port = descriptor + .environment + .port(&declared.port_id) + .ok_or_else(|| { + crate::scalar::wire_err("CommittedSnapshot: assigned port is not declared") + })?; + controls.validate_against(&port.controls)?; + } + } + Ok(()) + } +} + +impl DomainType for CommittedSnapshot { + const TYPE_NAME: &'static str = "CommittedSnapshot"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CommittedSnapshot")?; + let descriptor_revision = f.u64_string("descriptorRevision")?; + let publisher_incarnation = f.id("publisherIncarnation")?; + let scope = Scope::from_json(f.value("scope")?)?; + let episode_id = f.id("episodeId")?; + let sequence = f.u64_string("sequence")?; + let world_time = RationalNs::from_json(f.value("worldTime")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "CommittedSnapshot.agents")?; + let agent_id = a.id("agentId")?; + let telemetry = AgentTelemetry::from_json(a.value("telemetry")?)?; + let selected_decision = TypedValue::nullable_from_json(a.value("selectedDecision")?)?; + let applied_controls = match a.value("appliedControls")? { + Value::Null => None, + v => Some(PortControl::from_json(v)?), + }; + a.finish()?; + Ok(SnapshotAgent { + agent_id, + telemetry, + selected_decision, + applied_controls, + }) + })?; + let progress = TypedValue::from_json(f.value("progress")?)?; + let (views, audio) = { + let v = f.value("media")?; + let mut m = Fields::new(v, "CommittedSnapshot.media")?; + let views = view_list(&mut m, "views")?; + let audio = audio_list(&mut m, "audio")?; + m.finish()?; + (views, audio) + }; + let event_ids = id_list(&mut f, "eventIds", 0, MAX_SNAPSHOT_EVENTS)?; + f.finish()?; + let s = CommittedSnapshot { + descriptor_revision, + publisher_incarnation, + scope, + episode_id, + sequence, + world_time, + agents, + progress, + views, + audio, + event_ids, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("descriptorRevision", u64_json(self.descriptor_revision)), + ( + "publisherIncarnation", + self.publisher_incarnation.clone().into(), + ), + ("scope", self.scope.to_json()), + ("episodeId", self.episode_id.clone().into()), + ("sequence", u64_json(self.sequence)), + ("worldTime", self.world_time.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("telemetry", a.telemetry.to_json()), + ( + "selectedDecision", + TypedValue::nullable_to_json(a.selected_decision.as_ref()), + ), + ( + "appliedControls", + a.applied_controls + .as_ref() + .map_or(Value::Null, PortControl::to_json), + ), + ]) + }) + .collect(), + ), + ), + ("progress", self.progress.to_json()), + ( + "media", + obj(vec![ + ( + "views", + Value::Array(self.views.iter().map(ViewRef::to_json).collect()), + ), + ( + "audio", + Value::Array(self.audio.iter().map(AudioRef::to_json).collect()), + ), + ]), + ), + ( + "eventIds", + Value::Array(self.event_ids.iter().map(|e| e.clone().into()).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.publisher_incarnation) || !is_id(&self.episode_id) { + return err("CommittedSnapshot: publisherIncarnation and episodeId must be valid ids"); + } + self.scope.validate()?; + self.world_time.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("CommittedSnapshot: 1..=4 agents"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "CommittedSnapshot.agents", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) { + return err("CommittedSnapshot: agentId is not a valid id"); + } + agent.telemetry.validate()?; + if let Some(decision) = &agent.selected_decision { + decision.validate()?; + } + if let Some(controls) = &agent.applied_controls { + controls.validate()?; + } + // "Decisions/controls describe the transition ending at that boundary, null at + // initial boundary 0." (publishing-v1 section 3) + if self.scope.step == 0 + && (agent.selected_decision.is_some() || agent.applied_controls.is_some()) + { + return err( + "CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null", + ); + } + if self.scope.step > 0 + && (agent.selected_decision.is_none() || agent.applied_controls.is_none()) + { + return err( + "CommittedSnapshot: past boundary 0 every agent has a decision and applied controls", + ); + } + } + self.progress.validate()?; + if self.views.len() > MAX_VIEWS { + return err("CommittedSnapshot: at most 8 views"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "CommittedSnapshot.media.views", + )?; + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "CommittedSnapshot.media.audio", + )?; + require_unique( + self.event_ids.iter().map(String::as_str), + "CommittedSnapshot.eventIds", + )?; + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/rpc.rs b/services/flysim/crates/fly-session-types/src/rpc.rs new file mode 100644 index 0000000..e271cf9 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/rpc.rs @@ -0,0 +1,420 @@ +//! The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7. +//! +//! A domain reply is the `outcome` object inside a bus `rpc.result`. Bus route or admission +//! failure is not one of these: it never reaches a handler, so it cannot carry a mutation +//! certainty. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{ + DomainRequestId, DomainType, Result, Scope, bounded_string, constant, enumeration, err, is_id, + obj, +}; +use crate::workers::MAX_MESSAGE_CODE_POINTS; + +/// The domain error codes of ipc-v1 section 7. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ErrorCode { + /// Invalid schema/range, before mutation. + InvalidArgument, + /// Missing method or capability. + Unsupported, + /// Wrong session/profile/port/build/asset identity. + IdentityMismatch, + StaleEpoch, + StaleStep, + FutureStep, + /// Wrong worker phase. + InvalidPhase, + /// Existing logical operation with a changed id or body. + Conflict, + /// The original operation is still executing; this duplicate bus call started no work. + InProgress, + /// Domain capacity unavailable before admission. + Busy, + /// Missing, unowned or mismatched artifact, or an invalid media shape. + BufferInvalid, + /// Safe replay is no longer available; never recompute to replace it. + ResultExpired, + /// Restore validation failed before activation. + IncompatibleState, + BackendFailure, + Internal, +} + +impl ErrorCode { + pub const ALL: &'static [&'static str] = &[ + "INVALID_ARGUMENT", + "UNSUPPORTED", + "IDENTITY_MISMATCH", + "STALE_EPOCH", + "STALE_STEP", + "FUTURE_STEP", + "INVALID_PHASE", + "CONFLICT", + "IN_PROGRESS", + "BUSY", + "BUFFER_INVALID", + "RESULT_EXPIRED", + "INCOMPATIBLE_STATE", + "BACKEND_FAILURE", + "INTERNAL", + ]; + + pub fn as_str(self) -> &'static str { + match self { + ErrorCode::InvalidArgument => "INVALID_ARGUMENT", + ErrorCode::Unsupported => "UNSUPPORTED", + ErrorCode::IdentityMismatch => "IDENTITY_MISMATCH", + ErrorCode::StaleEpoch => "STALE_EPOCH", + ErrorCode::StaleStep => "STALE_STEP", + ErrorCode::FutureStep => "FUTURE_STEP", + ErrorCode::InvalidPhase => "INVALID_PHASE", + ErrorCode::Conflict => "CONFLICT", + ErrorCode::InProgress => "IN_PROGRESS", + ErrorCode::Busy => "BUSY", + ErrorCode::BufferInvalid => "BUFFER_INVALID", + ErrorCode::ResultExpired => "RESULT_EXPIRED", + ErrorCode::IncompatibleState => "INCOMPATIBLE_STATE", + ErrorCode::BackendFailure => "BACKEND_FAILURE", + ErrorCode::Internal => "INTERNAL", + } + } + + pub fn parse(s: &str) -> Result { + Ok(match s { + "INVALID_ARGUMENT" => ErrorCode::InvalidArgument, + "UNSUPPORTED" => ErrorCode::Unsupported, + "IDENTITY_MISMATCH" => ErrorCode::IdentityMismatch, + "STALE_EPOCH" => ErrorCode::StaleEpoch, + "STALE_STEP" => ErrorCode::StaleStep, + "FUTURE_STEP" => ErrorCode::FutureStep, + "INVALID_PHASE" => ErrorCode::InvalidPhase, + "CONFLICT" => ErrorCode::Conflict, + "IN_PROGRESS" => ErrorCode::InProgress, + "BUSY" => ErrorCode::Busy, + "BUFFER_INVALID" => ErrorCode::BufferInvalid, + "RESULT_EXPIRED" => ErrorCode::ResultExpired, + "INCOMPATIBLE_STATE" => ErrorCode::IncompatibleState, + "BACKEND_FAILURE" => ErrorCode::BackendFailure, + "INTERNAL" => ErrorCode::Internal, + _ => return err("code is not one of the fifteen domain error codes"), + }) + } + + /// The codes that are raised strictly before any mutation, so their certainty is `none`. + pub fn is_before_mutation(self) -> bool { + matches!( + self, + ErrorCode::InvalidArgument + | ErrorCode::Unsupported + | ErrorCode::IdentityMismatch + | ErrorCode::StaleEpoch + | ErrorCode::StaleStep + | ErrorCode::FutureStep + | ErrorCode::InvalidPhase + | ErrorCode::Conflict + | ErrorCode::InProgress + | ErrorCode::Busy + | ErrorCode::BufferInvalid + | ErrorCode::IncompatibleState + ) + } +} + +/// How certain the responder is that the operation mutated state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MutationCertainty { + /// Nothing was applied. + None, + /// The mutation completed. + Applied, + /// Completion is not established. "Errors after partial mutation use unknown unless + /// completion is established." (ipc-v1 section 7) + Unknown, +} + +impl MutationCertainty { + pub const ALL: &'static [&'static str] = &["none", "applied", "unknown"]; + + pub fn as_str(self) -> &'static str { + match self { + MutationCertainty::None => "none", + MutationCertainty::Applied => "applied", + MutationCertainty::Unknown => "unknown", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "none" => Ok(MutationCertainty::None), + "applied" => Ok(MutationCertainty::Applied), + "unknown" => Ok(MutationCertainty::Unknown), + _ => err("mutation must be none, applied or unknown"), + } + } +} + +/// `SessionRpcRequest`: one domain operation, independent of the bus callId that carries it. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcRequest { + pub request_id: DomainRequestId, + pub scope: Option, + pub params: Value, +} + +impl SessionRpcRequest { + /// The canonical body digest of this request under `method` (ipc-v1 section 5). + pub fn body_digest(&self, method: &str) -> Result { + canonical::body_digest(method, self.scope.as_ref(), &self.params) + } + + /// The operation key of a step mutation issued by `worker_id` under `method`. Lifecycle + /// calls with a null scope have no step operation key. + pub fn operation_key(&self, method: &str, worker_id: &str) -> Result { + let scope = self + .scope + .clone() + .ok_or_else(|| crate::scalar::wire_err("operation key: a step mutation has a scope"))?; + canonical::OperationKey::new(scope, method, worker_id) + } +} + +impl DomainType for SessionRpcRequest { + const TYPE_NAME: &'static str = "SessionRpcRequest"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcRequest")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let params = f.object("params")?.clone(); + f.finish()?; + let r = SessionRpcRequest { + request_id, + scope, + params: Value::Object(params), + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("requestId", self.request_id.to_json()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ("params", self.params.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + if !self.params.is_object() { + return err("SessionRpcRequest: params must be an object"); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + canonical::reject_bus_identities(&self.params) + } +} + +/// `SessionRpcSuccess`: a terminal domain success, echoing the request scope. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcSuccess { + pub request_id: DomainRequestId, + pub worker_id: String, + pub incarnation_id: String, + pub scope: Option, + pub result: Value, +} + +impl DomainType for SessionRpcSuccess { + const TYPE_NAME: &'static str = "SessionRpcSuccess"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcSuccess")?; + constant(&mut f, "type", "result")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let result = f.object("result")?.clone(); + f.finish()?; + let s = SessionRpcSuccess { + request_id, + worker_id, + incarnation_id, + scope, + result: Value::Object(result), + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("type", "result".into()), + ("requestId", self.request_id.to_json()), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ("result", self.result.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("SessionRpcSuccess: workerId and incarnationId must be valid ids"); + } + if !self.result.is_object() { + return err("SessionRpcSuccess: result must be an object"); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + Ok(()) + } +} + +/// `SessionRpcFailure`: a terminal domain error with an explicit mutation certainty. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcFailure { + pub request_id: DomainRequestId, + pub worker_id: String, + pub incarnation_id: String, + pub scope: Option, + pub code: ErrorCode, + pub message: String, + pub mutation: MutationCertainty, +} + +impl DomainType for SessionRpcFailure { + const TYPE_NAME: &'static str = "SessionRpcFailure"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcFailure")?; + constant(&mut f, "type", "error")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let (code, message, mutation) = { + let v = f.value("error")?; + let mut e = Fields::new(v, "SessionRpcFailure.error")?; + let code = ErrorCode::parse(&enumeration(&mut e, "code", ErrorCode::ALL)?)?; + let message = bounded_string(&mut e, "message", MAX_MESSAGE_CODE_POINTS)?; + let mutation = MutationCertainty::parse(&enumeration( + &mut e, + "mutation", + MutationCertainty::ALL, + )?)?; + e.finish()?; + (code, message, mutation) + }; + f.finish()?; + let failure = SessionRpcFailure { + request_id, + worker_id, + incarnation_id, + scope, + code, + message, + mutation, + }; + failure.validate()?; + Ok(failure) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("type", "error".into()), + ("requestId", self.request_id.to_json()), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ( + "error", + obj(vec![ + ("code", self.code.as_str().into()), + ("message", self.message.clone().into()), + ("mutation", self.mutation.as_str().into()), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("SessionRpcFailure: workerId and incarnationId must be valid ids"); + } + if self.message.chars().count() > MAX_MESSAGE_CODE_POINTS { + return err("SessionRpcFailure: message is at most 512 code points"); + } + if self.code.is_before_mutation() && self.mutation != MutationCertainty::None { + return err(format!( + "SessionRpcFailure: {} is raised before mutation, so mutation is \"none\"", + self.code.as_str() + )); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + Ok(()) + } +} + +/// A terminal domain outcome: success or failure. +#[derive(Clone, Debug, PartialEq)] +pub enum SessionRpcOutcome { + Success(SessionRpcSuccess), + Failure(SessionRpcFailure), +} + +impl SessionRpcOutcome { + pub fn request_id(&self) -> &DomainRequestId { + match self { + SessionRpcOutcome::Success(s) => &s.request_id, + SessionRpcOutcome::Failure(f) => &f.request_id, + } + } + + /// Replies echo the original scope (ipc-v1 section 3). + pub fn echoes(&self, request: &SessionRpcRequest) -> bool { + let scope = match self { + SessionRpcOutcome::Success(s) => &s.scope, + SessionRpcOutcome::Failure(f) => &f.scope, + }; + self.request_id() == &request.request_id && scope == &request.scope + } +} + +impl DomainType for SessionRpcOutcome { + const TYPE_NAME: &'static str = "SessionRpcOutcome"; + + fn from_json(value: &Value) -> Result { + let kind = value + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| crate::scalar::wire_err("SessionRpcOutcome: missing type"))?; + match kind { + "result" => SessionRpcSuccess::from_json(value).map(SessionRpcOutcome::Success), + "error" => SessionRpcFailure::from_json(value).map(SessionRpcOutcome::Failure), + _ => err("SessionRpcOutcome: type must be \"result\" or \"error\""), + } + } + + fn to_json(&self) -> Value { + match self { + SessionRpcOutcome::Success(s) => s.to_json(), + SessionRpcOutcome::Failure(f) => f.to_json(), + } + } + + fn validate(&self) -> Result<()> { + match self { + SessionRpcOutcome::Success(s) => s.validate(), + SessionRpcOutcome::Failure(f) => f.validate(), + } + } +} diff --git a/services/flysim/crates/fly-session-types/src/scalar.rs b/services/flysim/crates/fly-session-types/src/scalar.rs new file mode 100644 index 0000000..7bfc24d --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/scalar.rs @@ -0,0 +1,725 @@ +//! The domain scalars of ipc-v1 section 2, and the four identities that must never be confused. +//! +//! `Id`, `U64` and `Digest` are the bus encodings: this module calls straight into +//! [`flybus::wire`] instead of restating the regular expressions, and +//! `tests/encodings.rs` pins that the two agree. Everything else here is domain-only: +//! `Scope`, `RationalNs` (reduced, positive denominator, zero as `0/1`, checked arithmetic), +//! `SchemaRef` and `TypedValue` with its 32-KiB canonical-JSON cap. + +use std::cmp::Ordering; + +use flybus::wire::{self, Fields, WireError}; +use serde_json::{Map, Value}; + +use crate::canonical; + +pub type Result = std::result::Result; + +/// Every parsed domain type re-validates itself, so a value built in Rust and a value read +/// from JSON are held to the same rules. +pub trait DomainType: Sized { + /// The name this type has in the canonical schema set. + const TYPE_NAME: &'static str; + + /// Reads and validates one JSON value. Unknown fields are refused. + fn from_json(value: &Value) -> Result; + + /// The canonical JSON shape of this value. + fn to_json(&self) -> Value; + + /// The rules that are not expressible as one field read: ranges that depend on another + /// field, uniqueness, ordering and size caps. + fn validate(&self) -> Result<()>; +} + +pub fn err(message: impl Into) -> Result { + Err(WireError(message.into())) +} + +pub fn wire_err(message: impl Into) -> WireError { + WireError(message.into()) +} + +pub(crate) fn obj(pairs: Vec<(&str, Value)>) -> Value { + let mut map = Map::new(); + for (key, value) in pairs { + map.insert(key.to_owned(), value); + } + Value::Object(map) +} + +/// A `U64` field: the decimal string encoding, never a JSON number. +pub fn u64_json(n: u64) -> Value { + Value::String(n.to_string()) +} + +/// `Id`: `^[a-z0-9][a-z0-9._-]{0,63}$`, exactly the bus encoding. +pub fn is_id(s: &str) -> bool { + wire::is_id(s) +} + +/// `Digest`: 64 lowercase hexadecimal digits, exactly the bus encoding. +pub fn is_digest(s: &str) -> bool { + wire::is_digest(s) +} + +/// `U64`: `"0"` or `[1-9][0-9]*` up to `u64::MAX`, exactly the bus encoding. +pub fn parse_u64(s: &str) -> Option { + wire::parse_u64(s) +} + +// --------------------------------------------------------------------------------------------- +// Field readers the bus reader does not have + +/// A finite JSON number. NaN and infinities never survive strict parsing; this also refuses +/// integers outside the exactly representable double range, which canonical JSON cannot encode. +pub fn finite(f: &mut Fields<'_>, key: &'static str) -> Result { + let value = f.value(key)?; + match value { + Value::Number(n) => canonical::finite_double(n) + .ok_or_else(|| wire_err(format!("{key} must be a finite JSON number"))), + _ => err(format!("{key} must be a finite JSON number")), + } +} + +/// A finite JSON number inside `lo..=hi`, refused rather than clamped. +pub fn finite_in(f: &mut Fields<'_>, key: &'static str, lo: f64, hi: f64) -> Result { + let n = finite(f, key)?; + if n < lo || n > hi { + return err(format!("{key} must be in [{lo}, {hi}]")); + } + Ok(n) +} + +/// A JSON integer in `i32` range, the seed encoding Agent.Initialize uses. +pub fn i32_field(f: &mut Fields<'_>, key: &'static str) -> Result { + let value = f.value(key)?; + match value.as_i64() { + Some(n) if i64::from(i32::MIN) <= n && n <= i64::from(i32::MAX) => Ok(n as i32), + _ => err(format!("{key} must be a signed 32-bit integer")), + } +} + +/// One member of a closed string enum. +pub fn enumeration(f: &mut Fields<'_>, key: &'static str, allowed: &[&str]) -> Result { + let s = f.string(key)?; + if allowed.contains(&s) { + Ok(s.to_owned()) + } else { + err(format!("{key} must be one of {}", allowed.join(", "))) + } +} + +/// A string constant: a field whose only legal value is `expected`. +pub fn constant(f: &mut Fields<'_>, key: &'static str, expected: &str) -> Result<()> { + let s = f.string(key)?; + if s == expected { + Ok(()) + } else { + err(format!("{key} must be {expected:?}")) + } +} + +/// A `true` constant. +pub fn constant_true(f: &mut Fields<'_>, key: &'static str) -> Result<()> { + if f.boolean(key)? { + Ok(()) + } else { + err(format!("{key} must be true")) + } +} + +/// A string of at most `max` Unicode code points. +pub fn bounded_string(f: &mut Fields<'_>, key: &'static str, max: usize) -> Result { + let s = f.string(key)?; + if s.chars().count() > max { + return err(format!("{key} must be at most {max} code points")); + } + Ok(s.to_owned()) +} + +/// `null`, or a string of at most `max` code points. +pub fn nullable_bounded_string( + f: &mut Fields<'_>, + key: &'static str, + max: usize, +) -> Result> { + match f.value(key)? { + Value::Null => Ok(None), + _ => bounded_string(f, key, max).map(Some), + } +} + +/// Reads an array of `lo..=hi` items through `read`, keeping the supplied order. +pub fn list( + f: &mut Fields<'_>, + key: &'static str, + lo: usize, + hi: usize, + read: impl Fn(&Value) -> Result, +) -> Result> { + let items = f.array(key, lo, hi)?; + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(read(item).map_err(|e| wire_err(format!("{key}: {e}")))?); + } + Ok(out) +} + +/// An array of `lo..=hi` `Id`s. +pub fn id_list(f: &mut Fields<'_>, key: &'static str, lo: usize, hi: usize) -> Result> { + list(f, key, lo, hi, |v| match v.as_str() { + Some(s) if is_id(s) => Ok(s.to_owned()), + _ => err("every entry must be an id"), + }) +} + +/// Fails on the first repeated key, naming it. +pub fn require_unique<'a>(keys: impl IntoIterator, what: &str) -> Result<()> { + let mut seen: Vec<&str> = Vec::new(); + for key in keys { + if seen.contains(&key) { + return err(format!("{what}: duplicate {key:?}")); + } + seen.push(key); + } + Ok(()) +} + +/// Fails unless `actual` is exactly `expected`, in that order: descriptor order is part of +/// the contract, not a set membership test. +pub fn require_same_order<'a>( + actual: impl IntoIterator, + expected: impl IntoIterator, + what: &str, +) -> Result<()> { + let actual: Vec<&str> = actual.into_iter().collect(); + let expected: Vec<&str> = expected.into_iter().collect(); + if actual != expected { + return err(format!( + "{what}: must list [{}] in that order, found [{}]", + expected.join(", "), + actual.join(", ") + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Scope + +/// `Scope`: the simulation timeline identity. Never the bus route or store incarnation. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Scope { + pub session_id: String, + pub epoch: String, + pub step: u64, +} + +impl Scope { + pub fn new(session_id: &str, epoch: &str, step: u64) -> Result { + let scope = Scope { + session_id: session_id.to_owned(), + epoch: epoch.to_owned(), + step, + }; + scope.validate()?; + Ok(scope) + } + + /// `null`, or a scope. + pub fn nullable_from_json(value: &Value) -> Result> { + match value { + Value::Null => Ok(None), + _ => Scope::from_json(value).map(Some), + } + } + + pub fn nullable_to_json(scope: Option<&Scope>) -> Value { + scope.map_or(Value::Null, Scope::to_json) + } +} + +impl DomainType for Scope { + const TYPE_NAME: &'static str = "Scope"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Scope")?; + let session_id = f.id("sessionId")?; + let epoch = f.id("epoch")?; + let step = f.u64_string("step")?; + f.finish()?; + let scope = Scope { + session_id, + epoch, + step, + }; + scope.validate()?; + Ok(scope) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("epoch", self.epoch.clone().into()), + ("step", u64_json(self.step)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) { + return err("Scope: sessionId is not a valid id"); + } + if !is_id(&self.epoch) { + return err("Scope: epoch is not a valid id"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// RationalNs + +/// A nanosecond rational: reduced, positive denominator, zero encoded `0/1`. +/// +/// ipc-v1 section 2: "Fractions are reduced, denominators positive, durations positive; zero +/// is encoded 0/1. Arithmetic is checked." Durations are checked with +/// [`RationalNs::require_positive`] by the fields that are durations; `worldTime` and a tick +/// remainder are legitimately zero. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RationalNs { + pub numerator: u64, + pub denominator: u64, +} + +fn gcd(a: u64, b: u64) -> u64 { + let (mut a, mut b) = (a, b); + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} + +fn gcd128(a: u128, b: u128) -> u128 { + let (mut a, mut b) = (a, b); + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} + +impl RationalNs { + pub const ZERO: RationalNs = RationalNs { + numerator: 0, + denominator: 1, + }; + + /// Exactly the supplied pair, which must already be in canonical form. + pub fn new(numerator: u64, denominator: u64) -> Result { + let r = RationalNs { + numerator, + denominator, + }; + r.validate()?; + Ok(r) + } + + /// Reduces first, then validates: the constructor for arithmetic results. + pub fn reduced(numerator: u128, denominator: u128) -> Result { + if denominator == 0 { + return err("RationalNs: denominator must be positive"); + } + let (n, d) = if numerator == 0 { + (0u128, 1u128) + } else { + let g = gcd128(numerator, denominator); + (numerator / g, denominator / g) + }; + if n > u128::from(u64::MAX) || d > u128::from(u64::MAX) { + return err("RationalNs: reduced value does not fit U64"); + } + RationalNs::new(n as u64, d as u64) + } + + pub fn is_zero(&self) -> bool { + self.numerator == 0 + } + + /// Durations must be positive (ipc-v1 section 2). + pub fn require_positive(&self, what: &str) -> Result<()> { + if self.is_zero() { + return err(format!("{what}: duration must be positive")); + } + Ok(()) + } + + pub fn checked_add(&self, other: &RationalNs) -> Result { + let n = u128::from(self.numerator) * u128::from(other.denominator) + + u128::from(other.numerator) * u128::from(self.denominator); + let d = u128::from(self.denominator) * u128::from(other.denominator); + RationalNs::reduced(n, d) + } + + pub fn checked_sub(&self, other: &RationalNs) -> Result { + let left = u128::from(self.numerator) * u128::from(other.denominator); + let right = u128::from(other.numerator) * u128::from(self.denominator); + if right > left { + return err("RationalNs: subtraction would be negative"); + } + let d = u128::from(self.denominator) * u128::from(other.denominator); + RationalNs::reduced(left - right, d) + } + + pub fn checked_mul_u64(&self, k: u64) -> Result { + let n = u128::from(self.numerator) + .checked_mul(u128::from(k)) + .ok_or_else(|| wire_err("RationalNs: multiplication overflowed"))?; + RationalNs::reduced(n, u128::from(self.denominator)) + } + + /// The step-v1 section 5 accumulator: `ticks = floor(self / tick)` and the remainder + /// `self - ticks * tick`, which is always `>= 0` and `< tick`. + pub fn divide_floor(&self, tick: &RationalNs) -> Result<(u64, RationalNs)> { + tick.require_positive("RationalNs::divide_floor tick")?; + let n = u128::from(self.numerator) * u128::from(tick.denominator); + let d = u128::from(self.denominator) * u128::from(tick.numerator); + let ticks = n / d; + if ticks > u128::from(u64::MAX) { + return err("RationalNs: tick count does not fit U64"); + } + let ticks = ticks as u64; + let remainder = self.checked_sub(&tick.checked_mul_u64(ticks)?)?; + Ok((ticks, remainder)) + } +} + +impl PartialOrd for RationalNs { + fn partial_cmp(&self, other: &RationalNs) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RationalNs { + fn cmp(&self, other: &RationalNs) -> Ordering { + let left = u128::from(self.numerator) * u128::from(other.denominator); + let right = u128::from(other.numerator) * u128::from(self.denominator); + left.cmp(&right) + } +} + +impl DomainType for RationalNs { + const TYPE_NAME: &'static str = "RationalNs"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "RationalNs")?; + let numerator = f.u64_string("numerator")?; + let denominator = f.u64_string("denominator")?; + f.finish()?; + RationalNs::new(numerator, denominator) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("numerator", u64_json(self.numerator)), + ("denominator", u64_json(self.denominator)), + ]) + } + + fn validate(&self) -> Result<()> { + if self.denominator == 0 { + return err("RationalNs: denominator must be positive"); + } + if self.numerator == 0 && self.denominator != 1 { + return err("RationalNs: zero is encoded 0/1"); + } + if self.numerator != 0 && gcd(self.numerator, self.denominator) != 1 { + return err("RationalNs: fraction must be reduced"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// SchemaRef and TypedValue + +/// `SchemaRef`: the identity of a registered typed payload schema. Version is 1..=65535. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SchemaRef { + pub id: String, + pub version: u16, + pub digest: String, +} + +impl SchemaRef { + pub fn new(id: &str, version: u16, digest: &str) -> Result { + let r = SchemaRef { + id: id.to_owned(), + version, + digest: digest.to_owned(), + }; + r.validate()?; + Ok(r) + } +} + +impl DomainType for SchemaRef { + const TYPE_NAME: &'static str = "SchemaRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SchemaRef")?; + let id = f.id("id")?; + let version = f.int("version", 1, 65_535)? as u16; + let digest = f.string("digest")?.to_owned(); + f.finish()?; + let r = SchemaRef { + id, + version, + digest, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("version", Value::from(u64::from(self.version))), + ("digest", self.digest.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) { + return err("SchemaRef: id is not a valid id"); + } + if self.version == 0 { + return err("SchemaRef: version must be 1..=65535"); + } + if !is_digest(&self.digest) { + return err("SchemaRef: digest must be 64 lowercase hex digits"); + } + Ok(()) + } +} + +/// The canonical-JSON size limit of one `TypedValue` (ipc-v1 section 2, workers-v1 section 1). +pub const MAX_TYPED_VALUE_BYTES: usize = 32 * 1024; + +/// `TypedValue`: a schema identity plus an object, capped at 32 KiB of canonical JSON. +#[derive(Clone, Debug, PartialEq)] +pub struct TypedValue { + pub schema: SchemaRef, + pub value: Value, +} + +impl TypedValue { + pub fn new(schema: SchemaRef, value: Value) -> Result { + let t = TypedValue { schema, value }; + t.validate()?; + Ok(t) + } + + pub fn nullable_from_json(value: &Value) -> Result> { + match value { + Value::Null => Ok(None), + _ => TypedValue::from_json(value).map(Some), + } + } + + pub fn nullable_to_json(value: Option<&TypedValue>) -> Value { + value.map_or(Value::Null, TypedValue::to_json) + } + + /// The canonical JSON byte length of the whole typed value. + pub fn canonical_len(&self) -> Result { + canonical::canonicalize(&self.to_json()).map(|s| s.len()) + } +} + +impl DomainType for TypedValue { + const TYPE_NAME: &'static str = "TypedValue"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TypedValue")?; + let schema = SchemaRef::from_json(f.value("schema")?)?; + let inner = f.value("value")?.clone(); + f.finish()?; + let t = TypedValue { + schema, + value: inner, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("schema", self.schema.to_json()), + ("value", self.value.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + self.schema.validate()?; + if !self.value.is_object() { + return err("TypedValue: value must be an object"); + } + let len = self.canonical_len()?; + if len > MAX_TYPED_VALUE_BYTES { + return err(format!( + "TypedValue: {len} bytes of canonical JSON exceeds the {MAX_TYPED_VALUE_BYTES}-byte limit" + )); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// The four identities + +/// A bus RPC correlation id, `call-` (bus-v1 section 6). It is not a domain operation id: +/// a safe domain retry keeps its [`DomainRequestId`] and gets a new `BusCallId`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BusCallId(String); + +/// A domain operation id, `req-` plus a canonical `U64` serial (ipc-v1 section 5). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DomainRequestId(String); + +/// The identity of an immutable artifact: store incarnation, artifact id and generation. +/// Not an address, not authority to read, and not an [`crate::workers::AssetRef`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ArtifactIdentity { + pub store_id: String, + pub artifact_id: String, + pub generation: u64, +} + +/// Which kind of ownership root a token names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OwnerKind { + /// One recipient's delivery, `dlv-`. + Delivery, + /// An explicit artifact hold, `own-`. + Hold, +} + +/// A delivery or explicit-hold owner token. Connection-private: it never appears in a domain +/// payload or a canonical body digest. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct OwnerToken { + token: String, + kind: OwnerKind, +} + +macro_rules! serial_identity { + ($type:ty, $prefix:literal, $what:literal) => { + impl $type { + /// Parses the canonical `prefix-` form; any other prefix is refused, which is + /// what keeps the four identities from being swapped for one another. + pub fn parse(s: &str) -> Result { + match wire::parse_serial_id($prefix, s) { + Some(_) => Ok(Self(s.to_owned())), + None => err(concat!($what, " must be canonical ", $prefix, "-")), + } + } + + pub fn from_serial(serial: u64) -> Self { + Self(wire::serial_id($prefix, serial)) + } + + pub fn serial(&self) -> u64 { + wire::parse_serial_id($prefix, &self.0).expect("validated on construction") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn read(f: &mut Fields<'_>, key: &'static str) -> Result { + let s = f.string(key)?; + Self::parse(s).map_err(|e| wire_err(format!("{key}: {e}"))) + } + + pub fn read_nullable(f: &mut Fields<'_>, key: &'static str) -> Result> { + match f.value(key)? { + Value::Null => Ok(None), + _ => Self::read(f, key).map(Some), + } + } + + pub fn to_json(&self) -> Value { + Value::String(self.0.clone()) + } + } + }; +} + +serial_identity!(BusCallId, "call", "a bus callId"); +serial_identity!(DomainRequestId, "req", "a domain requestId"); + +impl OwnerToken { + pub fn parse(s: &str) -> Result { + if wire::parse_serial_id("dlv", s).is_some() { + return Ok(OwnerToken { + token: s.to_owned(), + kind: OwnerKind::Delivery, + }); + } + if wire::parse_serial_id("own", s).is_some() { + return Ok(OwnerToken { + token: s.to_owned(), + kind: OwnerKind::Hold, + }); + } + err("an owner token must be canonical dlv- or own-") + } + + pub fn delivery(serial: u64) -> OwnerToken { + OwnerToken { + token: wire::serial_id("dlv", serial), + kind: OwnerKind::Delivery, + } + } + + pub fn hold(serial: u64) -> OwnerToken { + OwnerToken { + token: wire::serial_id("own", serial), + kind: OwnerKind::Hold, + } + } + + pub fn kind(&self) -> OwnerKind { + self.kind + } + + pub fn as_str(&self) -> &str { + &self.token + } +} + +impl ArtifactIdentity { + /// The identity half of a bus `ArtifactRef`: the parts that name the bytes, without the + /// byte length, content type or optional digest. + pub fn of(reference: &flybus::wire::ArtifactRef) -> ArtifactIdentity { + ArtifactIdentity { + store_id: reference.store_id.clone(), + artifact_id: reference.artifact_id.clone(), + generation: reference.generation, + } + } + + pub fn validate(&self) -> Result<()> { + if !is_id(&self.store_id) { + return err("ArtifactIdentity: storeId is not a valid id"); + } + if !is_id(&self.artifact_id) { + return err("ArtifactIdentity: artifactId is not a valid id"); + } + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/schema.rs b/services/flysim/crates/fly-session-types/src/schema.rs new file mode 100644 index 0000000..9a122c0 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/schema.rs @@ -0,0 +1,1109 @@ +//! The canonical schema set, and `contractDigest`. +//! +//! The digest is taken over a *declaration*, not over this file's text: every type below is +//! a row of `(name, source document, fields)` and every field a row of +//! `(name, kind, required, constraint)`. Reformatting the source, reordering the rows, +//! renaming a Rust struct field or adding a comment cannot change the digest; changing a +//! type name, a JSON field name, a kind, a bound or a closed enum's members does. The rows +//! are sorted and rendered as canonical JSON (RFC 8785) before hashing, so two +//! implementations that agree on the schema set agree on the digest. +//! +//! `fixtures/schema-set.json` is the rendered set, and `fixtures/contract-digest.json` +//! records the digest; `tests/schema_set.rs` regenerates both, and the TypeScript package +//! hashes the same file with its own canonical JSON. + +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{Result, obj}; + +/// The schema set version. Bumped when the set gains or loses types, so an old digest is +/// never mistaken for a new one. +pub const SCHEMA_SET_VERSION: u64 = 1; + +/// One field of one type. +#[derive(Clone, Copy, Debug)] +pub struct FieldSchema { + pub name: &'static str, + pub kind: &'static str, + pub required: bool, + pub constraint: &'static str, +} + +/// One domain type. +#[derive(Clone, Copy, Debug)] +pub struct TypeSchema { + pub name: &'static str, + /// The contract and section this type is specified in. + pub source: &'static str, + pub fields: &'static [FieldSchema], +} + +/// One closed enum. +#[derive(Clone, Copy, Debug)] +pub struct EnumSchema { + pub name: &'static str, + pub source: &'static str, + pub members: &'static [&'static str], +} + +/// One named bound. +#[derive(Clone, Copy, Debug)] +pub struct LimitSchema { + pub name: &'static str, + pub value: u64, + /// Where the bound comes from: a contract section, or `crate` when this crate chose it + /// because the documents state none. + pub source: &'static str, +} + +const fn req(name: &'static str, kind: &'static str, constraint: &'static str) -> FieldSchema { + FieldSchema { + name, + kind, + required: true, + constraint, + } +} + +const fn opt(name: &'static str, kind: &'static str, constraint: &'static str) -> FieldSchema { + FieldSchema { + name, + kind, + required: false, + constraint, + } +} + +pub const ENUMS: &[EnumSchema] = &[ + EnumSchema { + name: "ErrorCode", + source: "ipc-v1 7", + members: crate::rpc::ErrorCode::ALL, + }, + EnumSchema { + name: "MutationCertainty", + source: "ipc-v1 3", + members: crate::rpc::MutationCertainty::ALL, + }, + EnumSchema { + name: "Role", + source: "ipc-v1 4", + members: crate::workers::Role::ALL, + }, + EnumSchema { + name: "WorkerState", + source: "ipc-v1 4", + members: crate::workers::WorkerState::ALL, + }, + EnumSchema { + name: "Recovery", + source: "workers-v1 3", + members: crate::workers::Recovery::ALL, + }, + EnumSchema { + name: "Determinism", + source: "workers-v1 3", + members: crate::workers::Determinism::ALL, + }, + EnumSchema { + name: "AxisRange", + source: "workers-v1 3", + members: crate::workers::AxisRange::ALL, + }, + EnumSchema { + name: "ViewFormat", + source: "state-media-v1 2", + members: &["rgba8"], + }, + EnumSchema { + name: "AudioFormat", + source: "state-media-v1 2", + members: &["f32le-interleaved"], + }, + EnumSchema { + name: "SchedulerId", + source: "publishing-v1 3", + members: &["lockstep-v1"], + }, + EnumSchema { + name: "EpisodeRequestKind", + source: "workers-v1 4", + members: &["terminal"], + }, +]; + +pub const LIMITS: &[LimitSchema] = &[ + LimitSchema { + name: "maxAgents", + value: crate::workers::MAX_AGENTS as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxPorts", + value: crate::workers::MAX_PORTS as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxRateRoles", + value: crate::workers::MAX_RATE_ROLES as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxStimuliPerOperation", + value: crate::workers::MAX_STIMULI as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxRewardsPerOperation", + value: crate::workers::MAX_REWARDS as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxViews", + value: crate::media::MAX_VIEWS as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxButtons", + value: crate::workers::MAX_BUTTONS as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxAxes", + value: crate::workers::MAX_AXES as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxAcknowledge", + value: crate::workers::MAX_ACKNOWLEDGE as u64, + source: "ipc-v1 5", + }, + LimitSchema { + name: "maxTypedValueBytes", + value: crate::scalar::MAX_TYPED_VALUE_BYTES as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxEnvelopeBytes", + value: canonical::MAX_ENVELOPE_BYTES as u64, + source: "bus-v1 4", + }, + LimitSchema { + name: "maxAttachments", + value: flybus::wire::MAX_ATTACHMENTS as u64, + source: "bus-v1 4", + }, + LimitSchema { + name: "maxMessageCodePoints", + value: crate::workers::MAX_MESSAGE_CODE_POINTS as u64, + source: "ipc-v1 7", + }, + LimitSchema { + name: "maxViewDimension", + value: crate::media::MAX_VIEW_DIMENSION, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxPixelAspectPart", + value: crate::media::MAX_PIXEL_ASPECT, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxObservationDelaySteps", + value: crate::media::MAX_OBSERVATION_DELAY_STEPS, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxSampleFrames", + value: crate::media::MAX_SAMPLE_FRAMES, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxEngineFrameLength", + value: crate::workers::MAX_ENGINE_FRAME_LEN as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxSchemaVersion", + value: 65_535, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxAudioStreams", + value: crate::media::MAX_AUDIO_STREAMS as u64, + source: "crate", + }, + LimitSchema { + name: "maxCapabilities", + value: crate::workers::MAX_CAPABILITIES as u64, + source: "crate", + }, + LimitSchema { + name: "maxSupportedMajors", + value: crate::workers::MAX_SUPPORTED_MAJORS as u64, + source: "crate", + }, + LimitSchema { + name: "maxSupportedStimuli", + value: crate::publishing::MAX_SUPPORTED_STIMULI as u64, + source: "crate", + }, + LimitSchema { + name: "maxAssets", + value: crate::publishing::MAX_ASSETS as u64, + source: "crate", + }, + LimitSchema { + name: "maxSnapshotEvents", + value: crate::publishing::MAX_SNAPSHOT_EVENTS as u64, + source: "crate", + }, +]; + +pub const SCHEMAS: &[TypeSchema] = &[ + TypeSchema { + name: "Scope", + source: "ipc-v1 2", + fields: &[ + req("sessionId", "Id", "^[a-z0-9][a-z0-9._-]{0,63}$"), + req("epoch", "Id", "^[a-z0-9][a-z0-9._-]{0,63}$"), + req("step", "U64", "decimal string, <= 18446744073709551615"), + ], + }, + TypeSchema { + name: "RationalNs", + source: "ipc-v1 2", + fields: &[ + req("numerator", "U64", "reduced against denominator"), + req( + "denominator", + "U64", + "positive; zero is encoded 0/1; arithmetic is checked", + ), + ], + }, + TypeSchema { + name: "SchemaRef", + source: "ipc-v1 2", + fields: &[ + req("id", "Id", ""), + req("version", "int", "1..=65535"), + req("digest", "Digest", "64 lowercase hex digits"), + ], + }, + TypeSchema { + name: "TypedValue", + source: "ipc-v1 2", + fields: &[ + req("schema", "SchemaRef", ""), + req( + "value", + "object", + "canonical JSON of the whole TypedValue <= 32768 bytes", + ), + ], + }, + TypeSchema { + name: "SessionRpcRequest", + source: "ipc-v1 2", + fields: &[ + req("requestId", "DomainRequestId", "req-"), + opt("scope", "Scope|null", "null for lifecycle calls"), + req("params", "object", "no bus callId/deliveryId/ownerId keys"), + ], + }, + TypeSchema { + name: "SessionRpcSuccess", + source: "ipc-v1 3", + fields: &[ + req("type", "const", "\"result\""), + req("requestId", "DomainRequestId", "echoes the request"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + opt("scope", "Scope|null", "echoes the request scope"), + req("result", "object", ""), + ], + }, + TypeSchema { + name: "SessionRpcFailure", + source: "ipc-v1 3", + fields: &[ + req("type", "const", "\"error\""), + req("requestId", "DomainRequestId", "echoes the request"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + opt("scope", "Scope|null", "echoes the request scope"), + req("error.code", "ErrorCode", ""), + req("error.message", "string", "<= 512 code points"), + req( + "error.mutation", + "MutationCertainty", + "none for every code raised before mutation", + ), + ], + }, + TypeSchema { + name: "AssetRef", + source: "workers-v1 1", + fields: &[ + req("id", "Id", ""), + req("digest", "Digest", ""), + req("byteLength", "U64", "positive"), + req("format", "Id", ""), + ], + }, + TypeSchema { + name: "SensoryInput", + source: "workers-v1 1", + fields: &[ + req("boundary", "U64", "the observed environment boundary"), + req( + "views", + "array", + "<= 8, unique viewId, producedStep <= boundary", + ), + opt( + "structured", + "TypedValue|null", + "a pixel-only profile rejects non-null", + ), + ], + }, + TypeSchema { + name: "Stimulus", + source: "workers-v1 1", + fields: &[ + req("id", "Id", "unique within its command namespace"), + req("kindId", "Id", "resolved through a profile capability"), + req("durationMs", "number", "finite and > 0"), + ], + }, + TypeSchema { + name: "Reward", + source: "workers-v1 1", + fields: &[ + req("eventId", "Id", "unique within its outcome namespace"), + req("ruleId", "Id", ""), + req( + "value", + "number", + "finite; positive-only profiles reject negatives", + ), + ], + }, + TypeSchema { + name: "AgentTelemetry", + source: "workers-v1 1", + fields: &[ + req("brainTicks", "U64", ""), + req("populationRateHz", "number", "finite and nonnegative"), + req( + "rates", + "array<{roleId:Id,hz:number}>", + "<= 64, unique roleId, profile order, finite nonnegative hz", + ), + req( + "learning", + "{enabled:bool,updates:U64,changed:U64,signal:number}", + "changed <= updates; signal finite", + ), + ], + }, + TypeSchema { + name: "AgentInitializeParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profile", "AssetRef", ""), + req("seed", "int", "signed 32-bit"), + req("initialInput", "SensoryInput", ""), + req("initialDecisionContext", "TypedValue", ""), + req("workerThreads", "int", ">= 1"), + ], + }, + TypeSchema { + name: "AgentInitializeResult", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("tickDuration", "RationalNs", "positive"), + req("warmupTicks", "U64", ""), + req("committedStep", "U64", "\"0\""), + req("decisionContextDigest", "Digest", ""), + req("telemetry", "AgentTelemetry", ""), + ], + }, + TypeSchema { + name: "PrepareParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("interval", "RationalNs", "positive"), + req("decisionContextDigest", "Digest", ""), + req( + "preStepStimulations", + "array", + "<= 64, unique id, supplied order retained", + ), + ], + }, + TypeSchema { + name: "PreparedDecision", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("ticksAdvanced", "U64", "<= brainTicks"), + req("brainTicks", "U64", ""), + req("remainder", "RationalNs", ">= 0 and < one model tick"), + req( + "decision", + "TypedValue", + "the profile's registered intent schema", + ), + ], + }, + TypeSchema { + name: "CommitParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("preparedRequestId", "DomainRequestId", ""), + req("nextInput", "SensoryInput", "boundary == scope.step + 1"), + req("nextDecisionContext", "TypedValue", ""), + req( + "rewards", + "array", + "<= 64, unique eventId, order retained", + ), + req( + "taskStimulations", + "array", + "<= 64, unique id, order retained", + ), + ], + }, + TypeSchema { + name: "AgentCommitResult", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("committedStep", "U64", "k+1"), + req("decisionContextDigest", "Digest", ""), + req("telemetry", "AgentTelemetry", ""), + ], + }, + TypeSchema { + name: "ControllerSchema", + source: "workers-v1 3", + fields: &[ + req("schema", "SchemaRef", ""), + req("buttons", "array", "<= 32, unique, fixed order"), + req( + "axes", + "array<{id:Id,range:AxisRange,neutral:number}>", + "<= 16, unique id, neutral inside its range", + ), + ], + }, + TypeSchema { + name: "PortControl", + source: "workers-v1 3", + fields: &[ + req("portId", "Id", ""), + req( + "buttons", + "array<{id:Id,down:bool}>", + "every declared button, descriptor order, no extras", + ), + req( + "axes", + "array<{id:Id,value:number}>", + "every declared axis in order; bipolar [-1,1], unit [0,1], refused not clamped", + ), + ], + }, + TypeSchema { + name: "EnvironmentDescriptor", + source: "workers-v1 3", + fields: &[ + req("backendDigest", "Digest", ""), + req("contentDigest", "Digest", ""), + req("configurationDigest", "Digest", ""), + req("stepDuration", "RationalNs", "fixed, reduced, positive"), + req( + "ports", + "array<{portId:Id,controls:ControllerSchema}>", + "1..=4, unique portId, fixed order", + ), + req("inspectionSchema", "SchemaRef", ""), + req("views", "array", "<= 8, unique viewId"), + req("audio", "array", "unique streamId"), + req("recovery", "Recovery", ""), + req("determinism", "Determinism", ""), + ], + }, + TypeSchema { + name: "EnvironmentInitializeParams", + source: "workers-v1 3", + fields: &[ + req("backendConfig", "AssetRef", ""), + req("taskConfig", "AssetRef", ""), + req("episodeId", "Id", ""), + req( + "portBindings", + "array<{portId:Id,agentId:Id}>", + "1..=4, unique portId and unique agentId", + ), + ], + }, + TypeSchema { + name: "EnvironmentInitializeResult", + source: "workers-v1 3", + fields: &[ + req("descriptor", "EnvironmentDescriptor", ""), + req( + "observation", + "WorldObservation", + "boundary 0 and worldTime 0/1", + ), + ], + }, + TypeSchema { + name: "WorldObservation", + source: "workers-v1 3", + fields: &[ + req("boundary", "U64", ""), + req( + "worldTime", + "RationalNs", + "logical time since episode start", + ), + opt("engineFrame", "string|null", "<= 64 characters"), + req("sensoryViews", "array", "<= 8, unique viewId"), + req( + "inspection", + "TypedValue", + "the descriptor's inspectionSchema", + ), + req("broadcastViews", "array", "<= 8, unique viewId"), + req("audio", "array", "unique streamId"), + ], + }, + TypeSchema { + name: "AdvanceParams", + source: "workers-v1 3", + fields: &[ + req("batchId", "Id", "unique within an epoch"), + req( + "controls", + "array", + "one complete batch: every declared port once, descriptor order", + ), + ], + }, + TypeSchema { + name: "StepResult", + source: "workers-v1 3", + fields: &[ + req("batchId", "Id", "echoes the request"), + req("appliedFromStep", "U64", "k"), + req("nextStep", "U64", "appliedFromStep + 1"), + req( + "appliedControlsDigest", + "Digest", + "over validated canonical requested controls", + ), + req("observation", "WorldObservation", "boundary == nextStep"), + ], + }, + TypeSchema { + name: "HelloParams", + source: "ipc-v1 4", + fields: &[ + req("sessionId", "Id", ""), + req("expectedWorkerId", "Id", ""), + req("role", "Role", ""), + req( + "supportedMajors", + "array", + "nonempty, unique, 1..=65535", + ), + ], + }, + TypeSchema { + name: "HelloResult", + source: "ipc-v1 4", + fields: &[ + req("selectedMajor", "const", "1"), + req("selectedMinor", "const", "0"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + req("role", "Role", ""), + req("buildDigest", "Digest", ""), + req("contractDigest", "Digest", ""), + req( + "capabilities", + "array", + "unique; agent-step-v1 or world-step-v1 required for that role", + ), + req( + "limits", + "{maxAgents:int,maxPorts:int}", + "1..=4 agents and 1..=4 ports", + ), + ], + }, + TypeSchema { + name: "StatusResult", + source: "ipc-v1 4", + fields: &[ + req("state", "WorkerState", ""), + opt("currentScope", "Scope|null", "null before initialization"), + opt("activeRequestId", "DomainRequestId|null", ""), + opt("lastCompletedRequestId", "DomainRequestId|null", ""), + opt("lastBatchId", "Id|null", ""), + req( + "progressCounter", + "U64", + "advances on progress, not on status queries", + ), + ], + }, + TypeSchema { + name: "AcknowledgeParams", + source: "ipc-v1 5", + fields: &[req( + "requestIds", + "array", + "1..=16, unique", + )], + }, + TypeSchema { + name: "AcknowledgeResult", + source: "ipc-v1 5", + fields: &[req( + "acknowledged", + "array", + "<= 16, unique, a subset of the request", + )], + }, + TypeSchema { + name: "ShutdownParams", + source: "ipc-v1 7", + fields: &[req("reason", "Id", "")], + }, + TypeSchema { + name: "ShutdownResult", + source: "ipc-v1 7", + fields: &[req("stopping", "const", "true")], + }, + TypeSchema { + name: "TaskEvent", + source: "workers-v1 4", + fields: &[ + req( + "id", + "Id", + "derived from epoch, source step, rule and ordinal", + ), + req("kindId", "Id", ""), + req( + "sourceStep", + "U64", + "the newly reached boundary, 0 for bootstrap", + ), + opt("agentId", "Id|null", ""), + req("payload", "TypedValue", ""), + ], + }, + TypeSchema { + name: "EpisodeRequest", + source: "workers-v1 4", + fields: &[ + req("kind", "EpisodeRequestKind", ""), + req("reason", "Id", ""), + req("outcome", "TypedValue", ""), + ], + }, + TypeSchema { + name: "ViewDescriptor", + source: "state-media-v1 2", + fields: &[ + req("viewId", "Id", ""), + req("width", "int", "1..=4096"), + req("height", "int", "1..=4096"), + req("format", "ViewFormat", ""), + req("rowStride", "int", "exactly 4 x width"), + req( + "pixelAspect", + "{numerator:int,denominator:int}", + "positive integers <= 65535", + ), + req("observationDelaySteps", "int", "0..=8"), + ], + }, + TypeSchema { + name: "ViewRef", + source: "state-media-v1 2", + fields: &[ + req("viewId", "Id", ""), + req( + "producedStep", + "U64", + "max(0, boundary - observationDelaySteps) for required sensory views", + ), + req( + "pixels", + "ArtifactRef", + "listed attachment; byteLength == rowStride x height", + ), + ], + }, + TypeSchema { + name: "AudioDescriptor", + source: "state-media-v1 2", + fields: &[ + req("streamId", "Id", ""), + req("sampleRate", "int", "8000..=192000"), + req("channels", "int", "1..=8"), + req("format", "AudioFormat", ""), + ], + }, + TypeSchema { + name: "AudioRef", + source: "state-media-v1 2", + fields: &[ + req("streamId", "Id", ""), + req("firstSample", "U64", "no overlap or rewind within an epoch"), + req("sampleFrames", "int", "0..=192000"), + req( + "samples", + "ArtifactRef", + "byteLength == sampleFrames x channels x 4, finite f32", + ), + req( + "discontinuity", + "bool", + "true on the first chunk after restore", + ), + ], + }, + TypeSchema { + name: "CaptureParams", + source: "state-media-v1 5", + fields: &[req("checkpointId", "Id", "")], + }, + TypeSchema { + name: "CaptureResult", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req("boundary", "U64", "the committed boundary"), + req("compatibilityDigest", "Digest", ""), + req( + "payload", + "ArtifactRef", + "listed attachment; digest required", + ), + ], + }, + TypeSchema { + name: "StageRestoreParams", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req("sourceScope", "Scope", "provenance, not the new handles"), + req("compatibilityDigest", "Digest", ""), + req("payload", "ArtifactRef", "newly imported; digest required"), + ], + }, + TypeSchema { + name: "StageRestoreResult", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req( + "restoreToken", + "Id", + "activates once, bound to scope and payload", + ), + ], + }, + TypeSchema { + name: "ActivateRestoreParams", + source: "state-media-v1 5", + fields: &[req("restoreToken", "Id", "")], + }, + TypeSchema { + name: "ActivateRestoreResult", + source: "state-media-v1 5", + fields: &[ + req("committedStep", "U64", ""), + req("checkpointId", "Id", ""), + opt( + "observation", + "WorldObservation|null", + "required from an environment, null from an agent", + ), + ], + }, + TypeSchema { + name: "SessionDescriptor", + source: "publishing-v1 3", + fields: &[ + req("sessionId", "Id", ""), + req("revision", "U64", ""), + req("compositionDigest", "Digest", ""), + req("schedulerId", "SchedulerId", ""), + req("environment", "EnvironmentDescriptor", ""), + req("taskSchema", "SchemaRef", ""), + req( + "agents", + "array", + "1..=4, unique agentId and portId, each portId declared by the environment", + ), + req("assets", "array", "unique id"), + ], + }, + TypeSchema { + name: "AgentDescriptor", + source: "publishing-v1 3", + fields: &[ + req("agentId", "Id", ""), + req("portId", "Id", ""), + req("profileDigest", "Digest", ""), + req("datasetDigest", "Digest", ""), + req( + "indexDigest", + "Digest", + "geometry mapping needs this, not neuronCount", + ), + req("neuronCount", "U64", ""), + req("rateRoles", "array", "<= 64, unique"), + req("supportedStimuli", "array", "unique"), + ], + }, + TypeSchema { + name: "CommittedSnapshot", + source: "publishing-v1 3", + fields: &[ + req("descriptorRevision", "U64", ""), + req("publisherIncarnation", "Id", ""), + req("scope", "Scope", "the committed boundary"), + req("episodeId", "Id", ""), + req("sequence", "U64", "monotonic within publisherIncarnation"), + req("worldTime", "RationalNs", ""), + req( + "agents", + "array", + "1..=4, unique agentId, telemetry in profile role order", + ), + req("progress", "TypedValue", ""), + req( + "media", + "{views:array,audio:array}", + "declared attachments held through publication admission", + ), + req("eventIds", "array", "unique, task order"), + ], + }, + TypeSchema { + name: "SnapshotAgent", + source: "publishing-v1 3", + fields: &[ + req("agentId", "Id", ""), + req("telemetry", "AgentTelemetry", ""), + opt( + "selectedDecision", + "TypedValue|null", + "null exactly at boundary 0", + ), + opt( + "appliedControls", + "PortControl|null", + "null exactly at boundary 0; the agent's assigned port", + ), + ], + }, + TypeSchema { + name: "TransitionTrace", + source: "step-v1 8", + fields: &[ + req("behaviour", "TraceBehaviour", "compared between runs"), + req( + "operational", + "TraceOperational", + "recorded, never compared: wall time and transport identities", + ), + ], + }, + TypeSchema { + name: "TraceBehaviour", + source: "step-v1 8", + fields: &[ + req("scope", "Scope", ""), + req( + "agents", + "array", + "sorted by agentId, independent of dispatch order", + ), + req("batchId", "Id", ""), + req("controlDigest", "Digest", ""), + req( + "acknowledgedBoundary", + "U64", + "the world boundary the environment acknowledged", + ), + req( + "observationBoundaries", + "array<{viewId:Id,producedStep:U64}>", + "sorted by viewId", + ), + req("outcomeIds", "array", "task outcome ids in task order"), + req("eventIds", "array", "task event ids in task order"), + req("publishedBoundary", "U64", ""), + ], + }, + TypeSchema { + name: "TraceAgent", + source: "step-v1 8", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("ticksAdvanced", "U64", ""), + req("brainTicks", "U64", ""), + req("remainder", "RationalNs", ""), + req("decisionDigest", "Digest", ""), + req("committedStep", "U64", "the commit acknowledgment"), + ], + }, + TypeSchema { + name: "TraceOperational", + source: "step-v1 8", + fields: &[ + req("wallTimeNs", "U64", ""), + req( + "prepareRequestIds", + "array<{agentId:Id,requestId:DomainRequestId}>", + "", + ), + req("advanceRequestId", "DomainRequestId", ""), + req( + "commitRequestIds", + "array<{agentId:Id,requestId:DomainRequestId}>", + "", + ), + req("busCallIds", "array", "call-"), + req("deliveryIds", "array", "dlv- or own-"), + ], + }, +]; + +/// The schema set as canonical-JSON-ready data. +pub fn schema_set() -> Value { + let mut types: Vec<&TypeSchema> = SCHEMAS.iter().collect(); + types.sort_by_key(|t| t.name); + let mut enums: Vec<&EnumSchema> = ENUMS.iter().collect(); + enums.sort_by_key(|e| e.name); + let mut limits: Vec<&LimitSchema> = LIMITS.iter().collect(); + limits.sort_by_key(|l| l.name); + + obj(vec![ + ("contract", "fly-session-types".into()), + ("version", Value::from(SCHEMA_SET_VERSION)), + ( + "scalars", + obj(vec![ + ("Id", "^[a-z0-9][a-z0-9._-]{0,63}$".into()), + ( + "U64", + "\"0\" or [1-9][0-9]*, <= 18446744073709551615".into(), + ), + ("Digest", "64 lowercase hexadecimal digits (SHA-256)".into()), + ("BusCallId", "call-".into()), + ("DomainRequestId", "req-".into()), + ("OwnerToken", "dlv- or own-".into()), + ( + "ArtifactIdentity", + "storeId, artifactId and generation of a bus ArtifactRef".into(), + ), + ]), + ), + ( + "limits", + Value::Array( + limits + .iter() + .map(|l| { + obj(vec![ + ("name", l.name.into()), + ("value", Value::from(l.value)), + ("source", l.source.into()), + ]) + }) + .collect(), + ), + ), + ( + "enums", + Value::Array( + enums + .iter() + .map(|e| { + obj(vec![ + ("name", e.name.into()), + ("source", e.source.into()), + ( + "members", + Value::Array(e.members.iter().map(|m| (*m).into()).collect()), + ), + ]) + }) + .collect(), + ), + ), + ( + "types", + Value::Array( + types + .iter() + .map(|t| { + obj(vec![ + ("name", t.name.into()), + ("source", t.source.into()), + ( + "fields", + Value::Array( + t.fields + .iter() + .map(|f| { + obj(vec![ + ("name", f.name.into()), + ("kind", f.kind.into()), + ("required", Value::Bool(f.required)), + ("constraint", f.constraint.into()), + ]) + }) + .collect(), + ), + ), + ]) + }) + .collect(), + ), + ), + ]) +} + +/// The canonical JSON text of the schema set. +pub fn schema_set_json() -> Result { + canonical::canonicalize(&schema_set()) +} + +/// `contractDigest`: the SHA-256 of the canonical schema set. +pub fn contract_digest() -> String { + canonical::digest_of(&schema_set()).expect("the schema set is canonicalizable") +} diff --git a/services/flysim/crates/fly-session-types/src/seed.rs b/services/flysim/crates/fly-session-types/src/seed.rs new file mode 100644 index 0000000..fbc5002 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/seed.rs @@ -0,0 +1,70 @@ +//! `seed-derivation-v1`: independent per-agent seeds from one recorded master seed. +//! +//! The specification is `docs/design/session-framework/seed-derivation-v1.md`; this is its +//! reference implementation, and `fixtures/seed-vectors.json` its test vectors, which the +//! TypeScript package reproduces. + +use sha2::{Digest as _, Sha256}; + +use crate::canonical; +use crate::scalar::{Result, err, is_id}; + +/// The algorithm identity. It is part of composition identity: changing any byte of the +/// derivation requires a new id. +pub const ALGORITHM: &str = "seed-derivation-v1"; + +/// The domain separation prefix hashed before the inputs. +pub const PREFIX: &str = "flybrain/seed-derivation-v1"; + +/// The SHA-256 of the derivation material for one agent, lowercase hex. +pub fn material_digest(master_seed: u64, agent_id: &str) -> Result { + Ok(canonical::sha256_hex(&material(master_seed, agent_id)?)) +} + +/// The exact bytes hashed: the prefix, the master seed as a canonical `U64` decimal string and +/// the agent id, each followed by one `\n`. +pub fn material(master_seed: u64, agent_id: &str) -> Result> { + if !is_id(agent_id) { + return err("seed derivation: agentId is not a valid id"); + } + Ok(format!("{PREFIX}\n{master_seed}\n{agent_id}\n").into_bytes()) +} + +/// The signed 32-bit seed `Agent.Initialize` takes for `agent_id`. +/// +/// The digest is read as eight big-endian `u32` lanes; the first nonzero lane becomes the +/// seed, reinterpreted as two's-complement `i32`. Skipping zero lanes keeps the seed usable +/// by an xorshift generator, whose state must not be zero. If every lane were zero the +/// material is rehashed with a counter suffix, which no observed input has needed. +pub fn agent_seed(master_seed: u64, agent_id: &str) -> Result { + let mut material = material(master_seed, agent_id)?; + for round in 0u32..4 { + if round > 0 { + material.extend_from_slice(format!("{round}\n").as_bytes()); + } + let digest = Sha256::digest(&material); + for lane in digest.chunks_exact(4) { + let word = u32::from_be_bytes([lane[0], lane[1], lane[2], lane[3]]); + if word != 0 { + return Ok(word as i32); + } + } + } + err("seed derivation: every lane of four digests was zero") +} + +/// The seeds of a whole composition, in the order the agent ids are given. +/// +/// Equal ids deliberately derive equal seeds: "Identical explicit seeds are allowed only when +/// the experiment intentionally declares them" (workers-v1 section 2), so a composition with a +/// repeated agent id is refused here rather than silently sharing a seed. +pub fn composition_seeds(master_seed: u64, agent_ids: &[String]) -> Result> { + crate::scalar::require_unique( + agent_ids.iter().map(String::as_str), + "seed derivation: agentIds", + )?; + agent_ids + .iter() + .map(|id| agent_seed(master_seed, id)) + .collect() +} diff --git a/services/flysim/crates/fly-session-types/src/trace.rs b/services/flysim/crates/fly-session-types/src/trace.rs new file mode 100644 index 0000000..da48f85 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/trace.rs @@ -0,0 +1,486 @@ +//! The trace format of step-v1 section 8, split into behaviour and operational metadata. +//! +//! Section 8 requires a record, for every transition, of the scope, the Prepare request ids, +//! the agent/profile ids, tick counts and remainders, decision digests, the complete batch id +//! and control digest, the acknowledged world boundary, observation producing boundaries, task +//! event/outcome ids in order, every Commit acknowledgment and the published boundary. It then +//! requires that sequential, concurrent and reversed runs "match, excluding wall time, request +//! ids and other explicitly operational metadata". +//! +//! So this record has two halves. [`TraceBehaviour`] is what must match: it is ordered by +//! agent id rather than by completion order, so a reversed dispatch produces an identical +//! value. [`TraceOperational`] is what section 8 requires recording but excludes from the +//! comparison: wall time, the domain request ids, the bus callIds and the delivery ids. +//! [`TransitionTrace::behaviour_equals`] compares only the first half, and +//! [`TransitionTrace::behaviour_diff`] names the fields that differ. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{ + BusCallId, DomainRequestId, DomainType, OwnerToken, RationalNs, Result, Scope, err, is_digest, + is_id, list, obj, require_unique, u64_json, +}; +use crate::workers::{MAX_AGENTS, MAX_RATE_ROLES}; + +/// One agent's behaviour in one transition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceAgent { + pub agent_id: String, + pub profile_digest: String, + pub ticks_advanced: u64, + pub brain_ticks: u64, + pub remainder: RationalNs, + pub decision_digest: String, + /// The boundary this agent acknowledged in its Commit reply. + pub committed_step: u64, +} + +/// One view's producing boundary, as observed in this transition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceObservation { + pub view_id: String, + pub produced_step: u64, +} + +/// The fields two runs of the same transition must agree on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceBehaviour { + pub scope: Scope, + /// Sorted by agent id, never by completion order. + pub agents: Vec, + pub batch_id: String, + pub control_digest: String, + pub acknowledged_boundary: u64, + /// Sorted by view id. + pub observation_boundaries: Vec, + /// Task outcome ids in task order. + pub outcome_ids: Vec, + /// Task event ids in task order. + pub event_ids: Vec, + pub published_boundary: u64, +} + +impl TraceBehaviour { + /// Sorts the order-free collections, so a trace recorded in completion order compares + /// equal to one recorded in dispatch order. + pub fn normalized(&self) -> TraceBehaviour { + let mut out = self.clone(); + out.agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id)); + out.observation_boundaries + .sort_by(|a, b| a.view_id.cmp(&b.view_id)); + out + } + + pub fn digest(&self) -> Result { + canonical::digest_of(&self.normalized().to_json()) + } +} + +impl DomainType for TraceBehaviour { + const TYPE_NAME: &'static str = "TraceBehaviour"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TraceBehaviour")?; + let scope = Scope::from_json(f.value("scope")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "TraceBehaviour.agents")?; + let agent_id = a.id("agentId")?; + let profile_digest = a.string("profileDigest")?.to_owned(); + let ticks_advanced = a.u64_string("ticksAdvanced")?; + let brain_ticks = a.u64_string("brainTicks")?; + let remainder = RationalNs::from_json(a.value("remainder")?)?; + let decision_digest = a.string("decisionDigest")?.to_owned(); + let committed_step = a.u64_string("committedStep")?; + a.finish()?; + Ok(TraceAgent { + agent_id, + profile_digest, + ticks_advanced, + brain_ticks, + remainder, + decision_digest, + committed_step, + }) + })?; + let batch_id = f.id("batchId")?; + let control_digest = f.string("controlDigest")?.to_owned(); + let acknowledged_boundary = f.u64_string("acknowledgedBoundary")?; + let observation_boundaries = list( + &mut f, + "observationBoundaries", + 0, + crate::media::MAX_VIEWS * 2, + |v| { + let mut o = Fields::new(v, "TraceBehaviour.observationBoundaries")?; + let view_id = o.id("viewId")?; + let produced_step = o.u64_string("producedStep")?; + o.finish()?; + Ok(TraceObservation { + view_id, + produced_step, + }) + }, + )?; + let outcome_ids = crate::scalar::id_list(&mut f, "outcomeIds", 0, MAX_RATE_ROLES)?; + let event_ids = crate::scalar::id_list(&mut f, "eventIds", 0, MAX_RATE_ROLES)?; + let published_boundary = f.u64_string("publishedBoundary")?; + f.finish()?; + let b = TraceBehaviour { + scope, + agents, + batch_id, + control_digest, + acknowledged_boundary, + observation_boundaries, + outcome_ids, + event_ids, + published_boundary, + }; + b.validate()?; + Ok(b) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("scope", self.scope.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("profileDigest", a.profile_digest.clone().into()), + ("ticksAdvanced", u64_json(a.ticks_advanced)), + ("brainTicks", u64_json(a.brain_ticks)), + ("remainder", a.remainder.to_json()), + ("decisionDigest", a.decision_digest.clone().into()), + ("committedStep", u64_json(a.committed_step)), + ]) + }) + .collect(), + ), + ), + ("batchId", self.batch_id.clone().into()), + ("controlDigest", self.control_digest.clone().into()), + ("acknowledgedBoundary", u64_json(self.acknowledged_boundary)), + ( + "observationBoundaries", + Value::Array( + self.observation_boundaries + .iter() + .map(|o| { + obj(vec![ + ("viewId", o.view_id.clone().into()), + ("producedStep", u64_json(o.produced_step)), + ]) + }) + .collect(), + ), + ), + ( + "outcomeIds", + Value::Array(self.outcome_ids.iter().map(|i| i.clone().into()).collect()), + ), + ( + "eventIds", + Value::Array(self.event_ids.iter().map(|i| i.clone().into()).collect()), + ), + ("publishedBoundary", u64_json(self.published_boundary)), + ]) + } + + fn validate(&self) -> Result<()> { + self.scope.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("TraceBehaviour: 1..=4 agents"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "TraceBehaviour.agents", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) { + return err("TraceBehaviour: agentId is not a valid id"); + } + if !is_digest(&agent.profile_digest) || !is_digest(&agent.decision_digest) { + return err("TraceBehaviour: agent digests must be 64 lowercase hex digits"); + } + agent.remainder.validate()?; + if agent.committed_step != self.scope.step + 1 { + return err( + "TraceBehaviour: every commit acknowledgment is the transition's next boundary", + ); + } + } + if !is_id(&self.batch_id) { + return err("TraceBehaviour: batchId is not a valid id"); + } + if !is_digest(&self.control_digest) { + return err("TraceBehaviour: controlDigest must be 64 lowercase hex digits"); + } + if self.acknowledged_boundary != self.scope.step + 1 { + return err("TraceBehaviour: the acknowledged boundary is scope.step + 1"); + } + if self.published_boundary != self.acknowledged_boundary { + return err( + "TraceBehaviour: the published boundary is the boundary every agent committed", + ); + } + require_unique( + self.observation_boundaries + .iter() + .map(|o| o.view_id.as_str()), + "TraceBehaviour.observationBoundaries", + )?; + require_unique( + self.event_ids.iter().map(String::as_str), + "TraceBehaviour.eventIds", + )?; + require_unique( + self.outcome_ids.iter().map(String::as_str), + "TraceBehaviour.outcomeIds", + )?; + Ok(()) + } +} + +/// One agent's domain request id for one phase. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceRequest { + pub agent_id: String, + pub request_id: DomainRequestId, +} + +/// What step-v1 section 8 records but excludes from the comparison. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceOperational { + /// Wall time is for pacing, health and presentation only (step-v1 section 5). + pub wall_time_ns: u64, + pub prepare_request_ids: Vec, + pub advance_request_id: DomainRequestId, + pub commit_request_ids: Vec, + /// The transport correlation ids this transition happened to use. A safe retry changes + /// these and nothing in [`TraceBehaviour`]. + pub bus_call_ids: Vec, + pub delivery_ids: Vec, +} + +impl DomainType for TraceOperational { + const TYPE_NAME: &'static str = "TraceOperational"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TraceOperational")?; + let wall_time_ns = f.u64_string("wallTimeNs")?; + let read_requests = |v: &Value| -> Result { + let mut r = Fields::new(v, "TraceOperational request")?; + let agent_id = r.id("agentId")?; + let request_id = DomainRequestId::read(&mut r, "requestId")?; + r.finish()?; + Ok(TraceRequest { + agent_id, + request_id, + }) + }; + let prepare_request_ids = list(&mut f, "prepareRequestIds", 1, MAX_AGENTS, read_requests)?; + let advance_request_id = DomainRequestId::read(&mut f, "advanceRequestId")?; + let commit_request_ids = list(&mut f, "commitRequestIds", 1, MAX_AGENTS, read_requests)?; + let bus_call_ids = list(&mut f, "busCallIds", 0, 64, |v| match v.as_str() { + Some(s) => BusCallId::parse(s), + None => err("every busCallId must be a string"), + })?; + let delivery_ids = list(&mut f, "deliveryIds", 0, 64, |v| match v.as_str() { + Some(s) => OwnerToken::parse(s), + None => err("every deliveryId must be a string"), + })?; + f.finish()?; + let o = TraceOperational { + wall_time_ns, + prepare_request_ids, + advance_request_id, + commit_request_ids, + bus_call_ids, + delivery_ids, + }; + o.validate()?; + Ok(o) + } + + fn to_json(&self) -> Value { + let requests = |items: &[TraceRequest]| { + Value::Array( + items + .iter() + .map(|r| { + obj(vec![ + ("agentId", r.agent_id.clone().into()), + ("requestId", r.request_id.to_json()), + ]) + }) + .collect(), + ) + }; + obj(vec![ + ("wallTimeNs", u64_json(self.wall_time_ns)), + ("prepareRequestIds", requests(&self.prepare_request_ids)), + ("advanceRequestId", self.advance_request_id.to_json()), + ("commitRequestIds", requests(&self.commit_request_ids)), + ( + "busCallIds", + Value::Array(self.bus_call_ids.iter().map(BusCallId::to_json).collect()), + ), + ( + "deliveryIds", + Value::Array( + self.delivery_ids + .iter() + .map(|t| Value::String(t.as_str().to_owned())) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + require_unique( + self.prepare_request_ids.iter().map(|r| r.agent_id.as_str()), + "TraceOperational.prepareRequestIds", + )?; + require_unique( + self.commit_request_ids.iter().map(|r| r.agent_id.as_str()), + "TraceOperational.commitRequestIds", + )?; + require_unique( + self.bus_call_ids.iter().map(BusCallId::as_str), + "TraceOperational.busCallIds", + )?; + require_unique( + self.delivery_ids.iter().map(OwnerToken::as_str), + "TraceOperational.deliveryIds", + )?; + Ok(()) + } +} + +/// One transition's trace: behaviour plus operational metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransitionTrace { + pub behaviour: TraceBehaviour, + pub operational: TraceOperational, +} + +impl TransitionTrace { + /// Behaviour equality: the comparison step-v1 section 8 asks for. + pub fn behaviour_equals(&self, other: &TransitionTrace) -> bool { + self.behaviour.normalized() == other.behaviour.normalized() + } + + /// The behaviour fields that differ, named. Empty when [`Self::behaviour_equals`] holds. + pub fn behaviour_diff(&self, other: &TransitionTrace) -> Vec { + let (a, b) = (self.behaviour.normalized(), other.behaviour.normalized()); + let mut out = Vec::new(); + if a.scope != b.scope { + out.push(format!("scope: {:?} vs {:?}", a.scope, b.scope)); + } + if a.batch_id != b.batch_id { + out.push(format!("batchId: {} vs {}", a.batch_id, b.batch_id)); + } + if a.control_digest != b.control_digest { + out.push("controlDigest differs".to_owned()); + } + if a.acknowledged_boundary != b.acknowledged_boundary { + out.push(format!( + "acknowledgedBoundary: {} vs {}", + a.acknowledged_boundary, b.acknowledged_boundary + )); + } + if a.published_boundary != b.published_boundary { + out.push(format!( + "publishedBoundary: {} vs {}", + a.published_boundary, b.published_boundary + )); + } + if a.observation_boundaries != b.observation_boundaries { + out.push("observationBoundaries differ".to_owned()); + } + if a.outcome_ids != b.outcome_ids { + out.push("outcomeIds differ".to_owned()); + } + if a.event_ids != b.event_ids { + out.push("eventIds differ".to_owned()); + } + let ids_a: Vec<&str> = a.agents.iter().map(|x| x.agent_id.as_str()).collect(); + let ids_b: Vec<&str> = b.agents.iter().map(|x| x.agent_id.as_str()).collect(); + if ids_a != ids_b { + out.push(format!( + "agents: [{}] vs [{}]", + ids_a.join(", "), + ids_b.join(", ") + )); + } else { + for (left, right) in a.agents.iter().zip(&b.agents) { + if left != right { + out.push(format!("agent {}: behaviour differs", left.agent_id)); + } + } + } + out + } + + /// Two whole runs agree on behaviour, transition by transition. + pub fn runs_equal(left: &[TransitionTrace], right: &[TransitionTrace]) -> bool { + left.len() == right.len() && left.iter().zip(right).all(|(a, b)| a.behaviour_equals(b)) + } +} + +impl DomainType for TransitionTrace { + const TYPE_NAME: &'static str = "TransitionTrace"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TransitionTrace")?; + let behaviour = TraceBehaviour::from_json(f.value("behaviour")?)?; + let operational = TraceOperational::from_json(f.value("operational")?)?; + f.finish()?; + let t = TransitionTrace { + behaviour, + operational, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("behaviour", self.behaviour.to_json()), + ("operational", self.operational.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + self.behaviour.validate()?; + self.operational.validate()?; + let behaviour_agents: Vec<&str> = self + .behaviour + .agents + .iter() + .map(|a| a.agent_id.as_str()) + .collect(); + for phase in [ + &self.operational.prepare_request_ids, + &self.operational.commit_request_ids, + ] { + for request in phase { + if !behaviour_agents.contains(&request.agent_id.as_str()) { + return err(format!( + "TransitionTrace: request recorded for {:?}, which is not in the transition", + request.agent_id + )); + } + } + } + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/workers.rs b/services/flysim/crates/fly-session-types/src/workers.rs new file mode 100644 index 0000000..a62cdb7 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/workers.rs @@ -0,0 +1,2405 @@ +//! The closed enums and method payloads of workers-v1. +//! +//! Every bound stated in that document is a constant here, and every constant is named in the +//! canonical schema set, so a bound cannot be changed without changing `contractDigest`. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::media::{AudioDescriptor, MAX_VIEWS, ViewDescriptor, ViewRef, audio_list, view_list}; +use crate::scalar::{ + DomainRequestId, DomainType, RationalNs, Result, SchemaRef, TypedValue, constant, + constant_true, enumeration, err, finite, finite_in, i32_field, id_list, is_digest, is_id, list, + obj, require_same_order, require_unique, u64_json, +}; + +/// First session composition limit: 4 agents (ipc-v1 section 2). +pub const MAX_AGENTS: usize = 4; +/// First session composition limit: 4 ports. +pub const MAX_PORTS: usize = 4; +/// 64 rate roles per agent. +pub const MAX_RATE_ROLES: usize = 64; +/// Arrays of stimuli or rewards are bounded to 64 per operation (workers-v1 section 1). +pub const MAX_STIMULI: usize = 64; +/// Arrays of stimuli or rewards are bounded to 64 per operation. +pub const MAX_REWARDS: usize = 64; +/// Controller buttons: <=32, unique, fixed order. +pub const MAX_BUTTONS: usize = 32; +/// Controller axes: <=16. +pub const MAX_AXES: usize = 16; +/// Worker.Acknowledge carries 1..=16 request ids, and 16 bounds the unacknowledged replies. +pub const MAX_ACKNOWLEDGE: usize = 16; +/// `engineFrame` is a backend-defined counter of at most 64 characters. +pub const MAX_ENGINE_FRAME_LEN: usize = 64; +/// Negotiated capability ids. Not a stated bound; recorded in the schema set. +pub const MAX_CAPABILITIES: usize = 32; +/// Supported majors in Worker.Hello. Not a stated bound; recorded in the schema set. +pub const MAX_SUPPORTED_MAJORS: usize = 8; +/// Domain error messages are <=512 code points (ipc-v1 section 7). +pub const MAX_MESSAGE_CODE_POINTS: usize = 512; + +/// A worker's negotiated role. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Role { + Agent, + Environment, + Coordinator, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::Agent => "agent", + Role::Environment => "environment", + Role::Coordinator => "coordinator", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "agent" => Ok(Role::Agent), + "environment" => Ok(Role::Environment), + "coordinator" => Ok(Role::Coordinator), + _ => err("role must be agent, environment or coordinator"), + } + } + + pub const ALL: &'static [&'static str] = &["agent", "environment", "coordinator"]; +} + +/// The worker phases of Worker.Status (ipc-v1 section 4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WorkerState { + Uninitialized, + Ready, + Preparing, + Prepared, + Advancing, + Committing, + Capturing, + StagedRestore, + Restoring, + Failed, + Stopping, +} + +impl WorkerState { + pub const ALL: &'static [&'static str] = &[ + "uninitialized", + "ready", + "preparing", + "prepared", + "advancing", + "committing", + "capturing", + "staged-restore", + "restoring", + "failed", + "stopping", + ]; + + pub fn as_str(self) -> &'static str { + match self { + WorkerState::Uninitialized => "uninitialized", + WorkerState::Ready => "ready", + WorkerState::Preparing => "preparing", + WorkerState::Prepared => "prepared", + WorkerState::Advancing => "advancing", + WorkerState::Committing => "committing", + WorkerState::Capturing => "capturing", + WorkerState::StagedRestore => "staged-restore", + WorkerState::Restoring => "restoring", + WorkerState::Failed => "failed", + WorkerState::Stopping => "stopping", + } + } + + pub fn parse(s: &str) -> Result { + Ok(match s { + "uninitialized" => WorkerState::Uninitialized, + "ready" => WorkerState::Ready, + "preparing" => WorkerState::Preparing, + "prepared" => WorkerState::Prepared, + "advancing" => WorkerState::Advancing, + "committing" => WorkerState::Committing, + "capturing" => WorkerState::Capturing, + "staged-restore" => WorkerState::StagedRestore, + "restoring" => WorkerState::Restoring, + "failed" => WorkerState::Failed, + "stopping" => WorkerState::Stopping, + _ => return err("state is not one of the eleven worker phases"), + }) + } +} + +/// How an environment recovers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recovery { + ExactCheckpoint, + EpisodeRestart, +} + +impl Recovery { + pub const ALL: &'static [&'static str] = &["exact-checkpoint", "episode-restart"]; + + pub fn as_str(self) -> &'static str { + match self { + Recovery::ExactCheckpoint => "exact-checkpoint", + Recovery::EpisodeRestart => "episode-restart", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "exact-checkpoint" => Ok(Recovery::ExactCheckpoint), + "episode-restart" => Ok(Recovery::EpisodeRestart), + _ => err("recovery must be exact-checkpoint or episode-restart"), + } + } +} + +/// How repeatable an environment claims to be. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Determinism { + FixedBuild, + Unverified, +} + +impl Determinism { + pub const ALL: &'static [&'static str] = &["fixed-build", "unverified"]; + + pub fn as_str(self) -> &'static str { + match self { + Determinism::FixedBuild => "fixed-build", + Determinism::Unverified => "unverified", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "fixed-build" => Ok(Determinism::FixedBuild), + "unverified" => Ok(Determinism::Unverified), + _ => err("determinism must be fixed-build or unverified"), + } + } +} + +/// An axis range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AxisRange { + Bipolar, + Unit, +} + +impl AxisRange { + pub const ALL: &'static [&'static str] = &["bipolar", "unit"]; + + pub fn as_str(self) -> &'static str { + match self { + AxisRange::Bipolar => "bipolar", + AxisRange::Unit => "unit", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "bipolar" => Ok(AxisRange::Bipolar), + "unit" => Ok(AxisRange::Unit), + _ => err("range must be bipolar or unit"), + } + } + + pub fn bounds(self) -> (f64, f64) { + match self { + AxisRange::Bipolar => (-1.0, 1.0), + AxisRange::Unit => (0.0, 1.0), + } + } + + pub fn contains(self, value: f64) -> bool { + let (lo, hi) = self.bounds(); + value.is_finite() && (lo..=hi).contains(&value) + } +} + +// --------------------------------------------------------------------------------------------- +// Shared data model (workers-v1 section 1) + +/// `AssetRef`: persistent installed content. Never a path or a URL, and never a transient +/// bus `ArtifactRef`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetRef { + pub id: String, + pub digest: String, + pub byte_length: u64, + pub format: String, +} + +impl DomainType for AssetRef { + const TYPE_NAME: &'static str = "AssetRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AssetRef")?; + let id = f.id("id")?; + let digest = f.string("digest")?.to_owned(); + let byte_length = f.u64_string("byteLength")?; + let format = f.id("format")?; + f.finish()?; + let a = AssetRef { + id, + digest, + byte_length, + format, + }; + a.validate()?; + Ok(a) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("digest", self.digest.clone().into()), + ("byteLength", u64_json(self.byte_length)), + ("format", self.format.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) { + return err("AssetRef: id is not a valid id"); + } + if !is_digest(&self.digest) { + return err("AssetRef: digest must be 64 lowercase hex digits"); + } + if self.byte_length == 0 { + return err("AssetRef: byteLength must be positive"); + } + if !is_id(&self.format) { + return err("AssetRef: format is not a valid id"); + } + Ok(()) + } +} + +/// `SensoryInput`: the views this agent may consume at one boundary, plus optional structured +/// input. A pixel-only profile rejects non-null structured input; that check needs the +/// profile, so it is [`SensoryInput::validate_for_profile`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SensoryInput { + pub boundary: u64, + pub views: Vec, + pub structured: Option, +} + +impl SensoryInput { + /// Views and structured input are separate capabilities (workers-v1 section 1). + pub fn validate_for_profile(&self, structured_sensing: bool) -> Result<()> { + self.validate()?; + if self.structured.is_some() && !structured_sensing { + return err("SensoryInput: a pixel-only profile rejects non-null structured input"); + } + Ok(()) + } + + /// Required sensory views must be produced at the boundary the descriptor's declared + /// delay implies, and their artifacts must have the descriptor's byte shape. + pub fn validate_against(&self, descriptors: &[ViewDescriptor]) -> Result<()> { + for view in &self.views { + let descriptor = descriptors + .iter() + .find(|d| d.view_id == view.view_id) + .ok_or_else(|| { + crate::scalar::wire_err(format!( + "SensoryInput: view {:?} is not declared by the environment", + view.view_id + )) + })?; + view.validate_against(descriptor, Some(self.boundary))?; + } + Ok(()) + } +} + +impl DomainType for SensoryInput { + const TYPE_NAME: &'static str = "SensoryInput"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SensoryInput")?; + let boundary = f.u64_string("boundary")?; + let views = view_list(&mut f, "views")?; + let structured = TypedValue::nullable_from_json(f.value("structured")?)?; + f.finish()?; + let s = SensoryInput { + boundary, + views, + structured, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("boundary", u64_json(self.boundary)), + ( + "views", + Value::Array(self.views.iter().map(ViewRef::to_json).collect()), + ), + ( + "structured", + TypedValue::nullable_to_json(self.structured.as_ref()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if self.views.len() > MAX_VIEWS { + return err("SensoryInput: at most 8 views per sensory input"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "SensoryInput.views", + )?; + for view in &self.views { + view.validate()?; + if view.produced_step > self.boundary { + return err(format!( + "SensoryInput: view {:?} was produced after the observed boundary", + view.view_id + )); + } + } + if let Some(structured) = &self.structured { + structured.validate()?; + } + Ok(()) + } +} + +/// `Stimulus`: a profile-declared stimulation kind with a positive finite duration. +#[derive(Clone, Debug, PartialEq)] +pub struct Stimulus { + pub id: String, + pub kind_id: String, + pub duration_ms: f64, +} + +impl DomainType for Stimulus { + const TYPE_NAME: &'static str = "Stimulus"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Stimulus")?; + let id = f.id("id")?; + let kind_id = f.id("kindId")?; + let duration_ms = finite(&mut f, "durationMs")?; + f.finish()?; + let s = Stimulus { + id, + kind_id, + duration_ms, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("kindId", self.kind_id.clone().into()), + ("durationMs", Value::from(self.duration_ms)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) || !is_id(&self.kind_id) { + return err("Stimulus: id and kindId must be valid ids"); + } + if !self.duration_ms.is_finite() || self.duration_ms <= 0.0 { + return err("Stimulus: durationMs must be finite and > 0"); + } + Ok(()) + } +} + +/// `Reward`: a finite value attributed to one task event. +#[derive(Clone, Debug, PartialEq)] +pub struct Reward { + pub event_id: String, + pub rule_id: String, + pub value: f64, +} + +impl Reward { + /// Shipped positive-only task profiles reject negatives (workers-v1 section 1). + pub fn validate_for_profile(&self, positive_only: bool) -> Result<()> { + self.validate()?; + if positive_only && self.value < 0.0 { + return err("Reward: this task profile is positive-only and rejects a negative value"); + } + Ok(()) + } +} + +impl DomainType for Reward { + const TYPE_NAME: &'static str = "Reward"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Reward")?; + let event_id = f.id("eventId")?; + let rule_id = f.id("ruleId")?; + let v = finite(&mut f, "value")?; + f.finish()?; + let r = Reward { + event_id, + rule_id, + value: v, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("eventId", self.event_id.clone().into()), + ("ruleId", self.rule_id.clone().into()), + ("value", Value::from(self.value)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.event_id) || !is_id(&self.rule_id) { + return err("Reward: eventId and ruleId must be valid ids"); + } + if !self.value.is_finite() { + return err("Reward: value must be finite"); + } + Ok(()) + } +} + +/// One tracked role's rate. +#[derive(Clone, Debug, PartialEq)] +pub struct RateSample { + pub role_id: String, + pub hz: f64, +} + +/// Learning telemetry. +#[derive(Clone, Debug, PartialEq)] +pub struct LearningTelemetry { + pub enabled: bool, + pub updates: u64, + pub changed: u64, + pub signal: f64, +} + +/// `AgentTelemetry`: rates in profile-defined order, unique by role id, at most 64. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentTelemetry { + pub brain_ticks: u64, + pub population_rate_hz: f64, + pub rates: Vec, + pub learning: LearningTelemetry, +} + +impl AgentTelemetry { + /// Rates are "in profile-defined order": the check needs the profile's role list. + pub fn validate_against_roles(&self, role_order: &[String]) -> Result<()> { + self.validate()?; + require_same_order( + self.rates.iter().map(|r| r.role_id.as_str()), + role_order.iter().map(String::as_str), + "AgentTelemetry.rates", + ) + } +} + +impl DomainType for AgentTelemetry { + const TYPE_NAME: &'static str = "AgentTelemetry"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentTelemetry")?; + let brain_ticks = f.u64_string("brainTicks")?; + let population_rate_hz = finite_in(&mut f, "populationRateHz", 0.0, f64::MAX)?; + let rates = list(&mut f, "rates", 0, MAX_RATE_ROLES, |v| { + let mut r = Fields::new(v, "AgentTelemetry.rates")?; + let role_id = r.id("roleId")?; + let hz = finite_in(&mut r, "hz", 0.0, f64::MAX)?; + r.finish()?; + Ok(RateSample { role_id, hz }) + })?; + let learning = { + let v = f.value("learning")?; + let mut l = Fields::new(v, "AgentTelemetry.learning")?; + let enabled = l.boolean("enabled")?; + let updates = l.u64_string("updates")?; + let changed = l.u64_string("changed")?; + let signal = finite(&mut l, "signal")?; + l.finish()?; + LearningTelemetry { + enabled, + updates, + changed, + signal, + } + }; + f.finish()?; + let t = AgentTelemetry { + brain_ticks, + population_rate_hz, + rates, + learning, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("brainTicks", u64_json(self.brain_ticks)), + ("populationRateHz", Value::from(self.population_rate_hz)), + ( + "rates", + Value::Array( + self.rates + .iter() + .map(|r| { + obj(vec![ + ("roleId", r.role_id.clone().into()), + ("hz", Value::from(r.hz)), + ]) + }) + .collect(), + ), + ), + ( + "learning", + obj(vec![ + ("enabled", Value::Bool(self.learning.enabled)), + ("updates", u64_json(self.learning.updates)), + ("changed", u64_json(self.learning.changed)), + ("signal", Value::from(self.learning.signal)), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if self.rates.len() > MAX_RATE_ROLES { + return err("AgentTelemetry: at most 64 rate roles per agent"); + } + require_unique( + self.rates.iter().map(|r| r.role_id.as_str()), + "AgentTelemetry.rates", + )?; + if !self.population_rate_hz.is_finite() || self.population_rate_hz < 0.0 { + return err("AgentTelemetry: populationRateHz must be finite and nonnegative"); + } + for rate in &self.rates { + if !is_id(&rate.role_id) { + return err("AgentTelemetry: roleId is not a valid id"); + } + if !rate.hz.is_finite() || rate.hz < 0.0 { + return err("AgentTelemetry: rates must be finite and nonnegative"); + } + } + if self.learning.changed > self.learning.updates { + return err("AgentTelemetry: learning.changed cannot exceed learning.updates"); + } + if !self.learning.signal.is_finite() { + return err("AgentTelemetry: learning.signal must be finite"); + } + Ok(()) + } +} + +/// A bounded, unique, order-preserving stimulus array. +pub fn stimulus_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let items = list(f, key, 0, MAX_STIMULI, Stimulus::from_json)?; + require_unique(items.iter().map(|s| s.id.as_str()), key)?; + Ok(items) +} + +/// A bounded, unique, order-preserving reward array. +pub fn reward_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let items = list(f, key, 0, MAX_REWARDS, Reward::from_json)?; + require_unique(items.iter().map(|r| r.event_id.as_str()), key)?; + Ok(items) +} + +// --------------------------------------------------------------------------------------------- +// Agent methods (workers-v1 section 2) + +/// `Agent.Initialize` params. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentInitializeParams { + pub agent_id: String, + pub profile: AssetRef, + pub seed: i32, + pub initial_input: SensoryInput, + pub initial_decision_context: TypedValue, + pub worker_threads: u64, +} + +impl DomainType for AgentInitializeParams { + const TYPE_NAME: &'static str = "AgentInitializeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentInitializeParams")?; + let agent_id = f.id("agentId")?; + let profile = AssetRef::from_json(f.value("profile")?)?; + let seed = i32_field(&mut f, "seed")?; + let initial_input = SensoryInput::from_json(f.value("initialInput")?)?; + let initial_decision_context = TypedValue::from_json(f.value("initialDecisionContext")?)?; + let worker_threads = f.int("workerThreads", 1, 4_096)?; + f.finish()?; + let p = AgentInitializeParams { + agent_id, + profile, + seed, + initial_input, + initial_decision_context, + worker_threads, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profile", self.profile.to_json()), + ("seed", Value::from(i64::from(self.seed))), + ("initialInput", self.initial_input.to_json()), + ( + "initialDecisionContext", + self.initial_decision_context.to_json(), + ), + ("workerThreads", Value::from(self.worker_threads)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("AgentInitializeParams: agentId is not a valid id"); + } + self.profile.validate()?; + self.initial_input.validate()?; + self.initial_decision_context.validate()?; + if self.worker_threads < 1 { + return err("AgentInitializeParams: workerThreads must be an integer >= 1"); + } + Ok(()) + } +} + +/// `Agent.Initialize` result. Scope is the new epoch at step 0, so `committedStep` is `"0"`. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentInitializeResult { + pub agent_id: String, + pub profile_digest: String, + pub tick_duration: RationalNs, + pub warmup_ticks: u64, + pub committed_step: u64, + pub decision_context_digest: String, + pub telemetry: AgentTelemetry, +} + +impl DomainType for AgentInitializeResult { + const TYPE_NAME: &'static str = "AgentInitializeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentInitializeResult")?; + let agent_id = f.id("agentId")?; + let profile_digest = f.string("profileDigest")?.to_owned(); + let tick_duration = RationalNs::from_json(f.value("tickDuration")?)?; + let warmup_ticks = f.u64_string("warmupTicks")?; + let committed_step = f.u64_string("committedStep")?; + let decision_context_digest = f.string("decisionContextDigest")?.to_owned(); + let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?; + f.finish()?; + let r = AgentInitializeResult { + agent_id, + profile_digest, + tick_duration, + warmup_ticks, + committed_step, + decision_context_digest, + telemetry, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profileDigest", self.profile_digest.clone().into()), + ("tickDuration", self.tick_duration.to_json()), + ("warmupTicks", u64_json(self.warmup_ticks)), + ("committedStep", u64_json(self.committed_step)), + ( + "decisionContextDigest", + self.decision_context_digest.clone().into(), + ), + ("telemetry", self.telemetry.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("AgentInitializeResult: agentId is not a valid id"); + } + if !is_digest(&self.profile_digest) || !is_digest(&self.decision_context_digest) { + return err("AgentInitializeResult: digests must be 64 lowercase hex digits"); + } + self.tick_duration.validate()?; + self.tick_duration + .require_positive("AgentInitializeResult.tickDuration")?; + if self.committed_step != 0 { + return err("AgentInitializeResult: committedStep must be \"0\""); + } + self.telemetry.validate() + } +} + +/// `Agent.Prepare` params. +#[derive(Clone, Debug, PartialEq)] +pub struct PrepareParams { + pub agent_id: String, + pub profile_digest: String, + pub interval: RationalNs, + pub decision_context_digest: String, + pub pre_step_stimulations: Vec, +} + +impl DomainType for PrepareParams { + const TYPE_NAME: &'static str = "PrepareParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PrepareParams")?; + let agent_id = f.id("agentId")?; + let profile_digest = f.string("profileDigest")?.to_owned(); + let interval = RationalNs::from_json(f.value("interval")?)?; + let decision_context_digest = f.string("decisionContextDigest")?.to_owned(); + let pre_step_stimulations = stimulus_list(&mut f, "preStepStimulations")?; + f.finish()?; + let p = PrepareParams { + agent_id, + profile_digest, + interval, + decision_context_digest, + pre_step_stimulations, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profileDigest", self.profile_digest.clone().into()), + ("interval", self.interval.to_json()), + ( + "decisionContextDigest", + self.decision_context_digest.clone().into(), + ), + ( + "preStepStimulations", + Value::Array( + self.pre_step_stimulations + .iter() + .map(Stimulus::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("PrepareParams: agentId is not a valid id"); + } + if !is_digest(&self.profile_digest) || !is_digest(&self.decision_context_digest) { + return err("PrepareParams: digests must be 64 lowercase hex digits"); + } + self.interval.validate()?; + self.interval.require_positive("PrepareParams.interval")?; + if self.pre_step_stimulations.len() > MAX_STIMULI { + return err("PrepareParams: at most 64 stimuli per operation"); + } + require_unique( + self.pre_step_stimulations.iter().map(|s| s.id.as_str()), + "PrepareParams.preStepStimulations", + )?; + for stimulus in &self.pre_step_stimulations { + stimulus.validate()?; + } + Ok(()) + } +} + +/// `Agent.Prepare` result. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedDecision { + pub agent_id: String, + pub ticks_advanced: u64, + pub brain_ticks: u64, + pub remainder: RationalNs, + pub decision: TypedValue, +} + +impl PreparedDecision { + /// The remainder is always `>= 0` and `< one model tick` (step-v1 section 5). + pub fn validate_remainder(&self, tick_duration: &RationalNs) -> Result<()> { + tick_duration.require_positive("tickDuration")?; + if self.remainder >= *tick_duration { + return err("PreparedDecision: remainder must be less than one model tick"); + } + Ok(()) + } +} + +impl DomainType for PreparedDecision { + const TYPE_NAME: &'static str = "PreparedDecision"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PreparedDecision")?; + let agent_id = f.id("agentId")?; + let ticks_advanced = f.u64_string("ticksAdvanced")?; + let brain_ticks = f.u64_string("brainTicks")?; + let remainder = RationalNs::from_json(f.value("remainder")?)?; + let decision = TypedValue::from_json(f.value("decision")?)?; + f.finish()?; + let d = PreparedDecision { + agent_id, + ticks_advanced, + brain_ticks, + remainder, + decision, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("ticksAdvanced", u64_json(self.ticks_advanced)), + ("brainTicks", u64_json(self.brain_ticks)), + ("remainder", self.remainder.to_json()), + ("decision", self.decision.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("PreparedDecision: agentId is not a valid id"); + } + if self.ticks_advanced > self.brain_ticks { + return err("PreparedDecision: ticksAdvanced cannot exceed the total brainTicks"); + } + self.remainder.validate()?; + self.decision.validate() + } +} + +/// `Agent.Commit` params. `nextInput.boundary` is `k+1`, checked against the request scope by +/// [`CommitParams::validate_against_scope`]. +#[derive(Clone, Debug, PartialEq)] +pub struct CommitParams { + pub agent_id: String, + pub prepared_request_id: DomainRequestId, + pub next_input: SensoryInput, + pub next_decision_context: TypedValue, + pub rewards: Vec, + pub task_stimulations: Vec, +} + +impl CommitParams { + /// The commit of transition `k -> k+1` carries `scope.step = k` and the input for `k+1`. + pub fn validate_against_scope(&self, scope: &crate::scalar::Scope) -> Result<()> { + self.validate()?; + let expected = scope + .step + .checked_add(1) + .ok_or_else(|| crate::scalar::wire_err("CommitParams: step overflows U64"))?; + if self.next_input.boundary != expected { + return err(format!( + "CommitParams: nextInput.boundary must be {expected} for scope.step {}", + scope.step + )); + } + Ok(()) + } +} + +impl DomainType for CommitParams { + const TYPE_NAME: &'static str = "CommitParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CommitParams")?; + let agent_id = f.id("agentId")?; + let prepared_request_id = DomainRequestId::read(&mut f, "preparedRequestId")?; + let next_input = SensoryInput::from_json(f.value("nextInput")?)?; + let next_decision_context = TypedValue::from_json(f.value("nextDecisionContext")?)?; + let rewards = reward_list(&mut f, "rewards")?; + let task_stimulations = stimulus_list(&mut f, "taskStimulations")?; + f.finish()?; + let p = CommitParams { + agent_id, + prepared_request_id, + next_input, + next_decision_context, + rewards, + task_stimulations, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("preparedRequestId", self.prepared_request_id.to_json()), + ("nextInput", self.next_input.to_json()), + ("nextDecisionContext", self.next_decision_context.to_json()), + ( + "rewards", + Value::Array(self.rewards.iter().map(Reward::to_json).collect()), + ), + ( + "taskStimulations", + Value::Array( + self.task_stimulations + .iter() + .map(Stimulus::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("CommitParams: agentId is not a valid id"); + } + self.next_input.validate()?; + self.next_decision_context.validate()?; + if self.rewards.len() > MAX_REWARDS || self.task_stimulations.len() > MAX_STIMULI { + return err("CommitParams: at most 64 rewards and 64 stimuli per operation"); + } + require_unique( + self.rewards.iter().map(|r| r.event_id.as_str()), + "CommitParams.rewards", + )?; + require_unique( + self.task_stimulations.iter().map(|s| s.id.as_str()), + "CommitParams.taskStimulations", + )?; + for reward in &self.rewards { + reward.validate()?; + } + for stimulus in &self.task_stimulations { + stimulus.validate()?; + } + Ok(()) + } +} + +/// `Agent.Commit` result. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentCommitResult { + pub agent_id: String, + pub committed_step: u64, + pub decision_context_digest: String, + pub telemetry: AgentTelemetry, +} + +impl DomainType for AgentCommitResult { + const TYPE_NAME: &'static str = "AgentCommitResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentCommitResult")?; + let agent_id = f.id("agentId")?; + let committed_step = f.u64_string("committedStep")?; + let decision_context_digest = f.string("decisionContextDigest")?.to_owned(); + let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?; + f.finish()?; + let r = AgentCommitResult { + agent_id, + committed_step, + decision_context_digest, + telemetry, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("committedStep", u64_json(self.committed_step)), + ( + "decisionContextDigest", + self.decision_context_digest.clone().into(), + ), + ("telemetry", self.telemetry.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("AgentCommitResult: agentId is not a valid id"); + } + if !is_digest(&self.decision_context_digest) { + return err("AgentCommitResult: decisionContextDigest must be 64 lowercase hex digits"); + } + self.telemetry.validate() + } +} + +// --------------------------------------------------------------------------------------------- +// Environment methods (workers-v1 section 3) + +/// One declared axis. +#[derive(Clone, Debug, PartialEq)] +pub struct AxisSchema { + pub id: String, + pub range: AxisRange, + pub neutral: f64, +} + +/// `ControllerSchema`: buttons and axes in fixed order, unique, bounded. +#[derive(Clone, Debug, PartialEq)] +pub struct ControllerSchema { + pub schema: SchemaRef, + pub buttons: Vec, + pub axes: Vec, +} + +impl DomainType for ControllerSchema { + const TYPE_NAME: &'static str = "ControllerSchema"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ControllerSchema")?; + let schema = SchemaRef::from_json(f.value("schema")?)?; + let buttons = id_list(&mut f, "buttons", 0, MAX_BUTTONS)?; + let axes = list(&mut f, "axes", 0, MAX_AXES, |v| { + let mut a = Fields::new(v, "ControllerSchema.axes")?; + let id = a.id("id")?; + let range = AxisRange::parse(&enumeration(&mut a, "range", AxisRange::ALL)?)?; + let (lo, hi) = range.bounds(); + let neutral = finite_in(&mut a, "neutral", lo, hi)?; + a.finish()?; + Ok(AxisSchema { id, range, neutral }) + })?; + f.finish()?; + let c = ControllerSchema { + schema, + buttons, + axes, + }; + c.validate()?; + Ok(c) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("schema", self.schema.to_json()), + ( + "buttons", + Value::Array(self.buttons.iter().map(|b| b.clone().into()).collect()), + ), + ( + "axes", + Value::Array( + self.axes + .iter() + .map(|a| { + obj(vec![ + ("id", a.id.clone().into()), + ("range", a.range.as_str().into()), + ("neutral", Value::from(a.neutral)), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.schema.validate()?; + if self.buttons.len() > MAX_BUTTONS { + return err("ControllerSchema: at most 32 buttons"); + } + if self.axes.len() > MAX_AXES { + return err("ControllerSchema: at most 16 axes"); + } + require_unique( + self.buttons.iter().map(String::as_str), + "ControllerSchema.buttons", + )?; + require_unique( + self.axes.iter().map(|a| a.id.as_str()), + "ControllerSchema.axes", + )?; + for button in &self.buttons { + if !is_id(button) { + return err("ControllerSchema: a button id is not a valid id"); + } + } + for axis in &self.axes { + if !is_id(&axis.id) { + return err("ControllerSchema: an axis id is not a valid id"); + } + if !axis.range.contains(axis.neutral) { + return err(format!( + "ControllerSchema: axis {:?} neutral must lie in its {} range", + axis.id, + axis.range.as_str() + )); + } + } + Ok(()) + } +} + +/// One button state in a port control. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ButtonState { + pub id: String, + pub down: bool, +} + +/// One axis value in a port control. +#[derive(Clone, Debug, PartialEq)] +pub struct AxisValue { + pub id: String, + pub value: f64, +} + +/// `PortControl`: one port's complete control state for one transition. +#[derive(Clone, Debug, PartialEq)] +pub struct PortControl { + pub port_id: String, + pub buttons: Vec, + pub axes: Vec, +} + +impl PortControl { + /// "Every active port control must include every declared button and axis in descriptor + /// order. All IDs must match exactly; no duplicates, extra controls or omissions. Bipolar + /// axes are finite [-1,1], unit axes [0,1] ... Do not silently clamp an out-of-range + /// caller value." (workers-v1 section 3) + pub fn validate_against(&self, controls: &ControllerSchema) -> Result<()> { + self.validate()?; + require_same_order( + self.buttons.iter().map(|b| b.id.as_str()), + controls.buttons.iter().map(String::as_str), + "PortControl.buttons", + )?; + require_same_order( + self.axes.iter().map(|a| a.id.as_str()), + controls.axes.iter().map(|a| a.id.as_str()), + "PortControl.axes", + )?; + for (value, schema) in self.axes.iter().zip(&controls.axes) { + if !schema.range.contains(value.value) { + return err(format!( + "PortControl: axis {:?} value {} is outside its {} range and is refused, not clamped", + value.id, + value.value, + schema.range.as_str() + )); + } + } + Ok(()) + } +} + +impl DomainType for PortControl { + const TYPE_NAME: &'static str = "PortControl"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PortControl")?; + let port_id = f.id("portId")?; + let buttons = list(&mut f, "buttons", 0, MAX_BUTTONS, |v| { + let mut b = Fields::new(v, "PortControl.buttons")?; + let id = b.id("id")?; + let down = b.boolean("down")?; + b.finish()?; + Ok(ButtonState { id, down }) + })?; + let axes = list(&mut f, "axes", 0, MAX_AXES, |v| { + let mut a = Fields::new(v, "PortControl.axes")?; + let id = a.id("id")?; + let value = finite(&mut a, "value")?; + a.finish()?; + Ok(AxisValue { id, value }) + })?; + f.finish()?; + let c = PortControl { + port_id, + buttons, + axes, + }; + c.validate()?; + Ok(c) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("portId", self.port_id.clone().into()), + ( + "buttons", + Value::Array( + self.buttons + .iter() + .map(|b| { + obj(vec![ + ("id", b.id.clone().into()), + ("down", Value::Bool(b.down)), + ]) + }) + .collect(), + ), + ), + ( + "axes", + Value::Array( + self.axes + .iter() + .map(|a| { + obj(vec![ + ("id", a.id.clone().into()), + ("value", Value::from(a.value)), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.port_id) { + return err("PortControl: portId is not a valid id"); + } + if self.buttons.len() > MAX_BUTTONS || self.axes.len() > MAX_AXES { + return err("PortControl: at most 32 buttons and 16 axes"); + } + require_unique( + self.buttons.iter().map(|b| b.id.as_str()), + "PortControl.buttons", + )?; + require_unique(self.axes.iter().map(|a| a.id.as_str()), "PortControl.axes")?; + for axis in &self.axes { + if !axis.value.is_finite() { + return err("PortControl: axis values must be finite"); + } + } + Ok(()) + } +} + +/// One port and the controller schema it accepts. +#[derive(Clone, Debug, PartialEq)] +pub struct PortDescriptor { + pub port_id: String, + pub controls: ControllerSchema, +} + +/// `EnvironmentDescriptor`: the fixed shape of one world for one epoch. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentDescriptor { + pub backend_digest: String, + pub content_digest: String, + pub configuration_digest: String, + pub step_duration: RationalNs, + pub ports: Vec, + pub inspection_schema: SchemaRef, + pub views: Vec, + pub audio: Vec, + pub recovery: Recovery, + pub determinism: Determinism, +} + +impl EnvironmentDescriptor { + pub fn port(&self, port_id: &str) -> Option<&PortDescriptor> { + self.ports.iter().find(|p| p.port_id == port_id) + } + + pub fn view(&self, view_id: &str) -> Option<&ViewDescriptor> { + self.views.iter().find(|v| v.view_id == view_id) + } + + pub fn audio_stream(&self, stream_id: &str) -> Option<&AudioDescriptor> { + self.audio.iter().find(|a| a.stream_id == stream_id) + } + + /// One complete batch: every configured port exactly once, in descriptor order, each + /// control validated against its own schema (step-v1 section 3 phase B). + pub fn validate_batch(&self, controls: &[PortControl]) -> Result<()> { + require_same_order( + controls.iter().map(|c| c.port_id.as_str()), + self.ports.iter().map(|p| p.port_id.as_str()), + "Environment.Advance controls", + )?; + for (control, port) in controls.iter().zip(&self.ports) { + control.validate_against(&port.controls)?; + } + Ok(()) + } +} + +impl DomainType for EnvironmentDescriptor { + const TYPE_NAME: &'static str = "EnvironmentDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentDescriptor")?; + let backend_digest = f.string("backendDigest")?.to_owned(); + let content_digest = f.string("contentDigest")?.to_owned(); + let configuration_digest = f.string("configurationDigest")?.to_owned(); + let step_duration = RationalNs::from_json(f.value("stepDuration")?)?; + let ports = list(&mut f, "ports", 1, MAX_PORTS, |v| { + let mut p = Fields::new(v, "EnvironmentDescriptor.ports")?; + let port_id = p.id("portId")?; + let controls = ControllerSchema::from_json(p.value("controls")?)?; + p.finish()?; + Ok(PortDescriptor { port_id, controls }) + })?; + let inspection_schema = SchemaRef::from_json(f.value("inspectionSchema")?)?; + let views = list(&mut f, "views", 0, MAX_VIEWS, ViewDescriptor::from_json)?; + let audio = list( + &mut f, + "audio", + 0, + crate::media::MAX_AUDIO_STREAMS, + AudioDescriptor::from_json, + )?; + let recovery = Recovery::parse(&enumeration(&mut f, "recovery", Recovery::ALL)?)?; + let determinism = + Determinism::parse(&enumeration(&mut f, "determinism", Determinism::ALL)?)?; + f.finish()?; + let d = EnvironmentDescriptor { + backend_digest, + content_digest, + configuration_digest, + step_duration, + ports, + inspection_schema, + views, + audio, + recovery, + determinism, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("backendDigest", self.backend_digest.clone().into()), + ("contentDigest", self.content_digest.clone().into()), + ( + "configurationDigest", + self.configuration_digest.clone().into(), + ), + ("stepDuration", self.step_duration.to_json()), + ( + "ports", + Value::Array( + self.ports + .iter() + .map(|p| { + obj(vec![ + ("portId", p.port_id.clone().into()), + ("controls", p.controls.to_json()), + ]) + }) + .collect(), + ), + ), + ("inspectionSchema", self.inspection_schema.to_json()), + ( + "views", + Value::Array(self.views.iter().map(ViewDescriptor::to_json).collect()), + ), + ( + "audio", + Value::Array(self.audio.iter().map(AudioDescriptor::to_json).collect()), + ), + ("recovery", self.recovery.as_str().into()), + ("determinism", self.determinism.as_str().into()), + ]) + } + + fn validate(&self) -> Result<()> { + for (what, digest) in [ + ("backendDigest", &self.backend_digest), + ("contentDigest", &self.content_digest), + ("configurationDigest", &self.configuration_digest), + ] { + if !is_digest(digest) { + return err(format!( + "EnvironmentDescriptor: {what} must be 64 lowercase hex digits" + )); + } + } + self.step_duration.validate()?; + self.step_duration + .require_positive("EnvironmentDescriptor.stepDuration")?; + if self.ports.is_empty() || self.ports.len() > MAX_PORTS { + return err("EnvironmentDescriptor: 1..=4 ports in the first composition"); + } + require_unique( + self.ports.iter().map(|p| p.port_id.as_str()), + "EnvironmentDescriptor.ports", + )?; + for port in &self.ports { + if !is_id(&port.port_id) { + return err("EnvironmentDescriptor: portId is not a valid id"); + } + port.controls.validate()?; + } + self.inspection_schema.validate()?; + if self.views.len() > MAX_VIEWS { + return err("EnvironmentDescriptor: at most 8 views"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "EnvironmentDescriptor.views", + )?; + for view in &self.views { + view.validate()?; + } + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "EnvironmentDescriptor.audio", + )?; + for stream in &self.audio { + stream.validate()?; + } + Ok(()) + } +} + +/// `Environment.Initialize` params. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentInitializeParams { + pub backend_config: AssetRef, + pub task_config: AssetRef, + pub episode_id: String, + pub port_bindings: Vec<(String, String)>, +} + +impl DomainType for EnvironmentInitializeParams { + const TYPE_NAME: &'static str = "EnvironmentInitializeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentInitializeParams")?; + let backend_config = AssetRef::from_json(f.value("backendConfig")?)?; + let task_config = AssetRef::from_json(f.value("taskConfig")?)?; + let episode_id = f.id("episodeId")?; + let port_bindings = list(&mut f, "portBindings", 1, MAX_PORTS, |v| { + let mut b = Fields::new(v, "EnvironmentInitializeParams.portBindings")?; + let port_id = b.id("portId")?; + let agent_id = b.id("agentId")?; + b.finish()?; + Ok((port_id, agent_id)) + })?; + f.finish()?; + let p = EnvironmentInitializeParams { + backend_config, + task_config, + episode_id, + port_bindings, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("backendConfig", self.backend_config.to_json()), + ("taskConfig", self.task_config.to_json()), + ("episodeId", self.episode_id.clone().into()), + ( + "portBindings", + Value::Array( + self.port_bindings + .iter() + .map(|(port_id, agent_id)| { + obj(vec![ + ("portId", port_id.clone().into()), + ("agentId", agent_id.clone().into()), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.backend_config.validate()?; + self.task_config.validate()?; + if !is_id(&self.episode_id) { + return err("EnvironmentInitializeParams: episodeId is not a valid id"); + } + if self.port_bindings.is_empty() || self.port_bindings.len() > MAX_PORTS { + return err("EnvironmentInitializeParams: 1..=4 port bindings"); + } + require_unique( + self.port_bindings.iter().map(|(p, _)| p.as_str()), + "EnvironmentInitializeParams.portBindings portId", + )?; + require_unique( + self.port_bindings.iter().map(|(_, a)| a.as_str()), + "EnvironmentInitializeParams.portBindings agentId", + )?; + Ok(()) + } +} + +/// `WorldObservation`: one coherent world boundary. +#[derive(Clone, Debug, PartialEq)] +pub struct WorldObservation { + pub boundary: u64, + pub world_time: RationalNs, + pub engine_frame: Option, + pub sensory_views: Vec, + pub inspection: TypedValue, + pub broadcast_views: Vec, + pub audio: Vec, +} + +impl WorldObservation { + /// Byte shapes, producing boundaries and declared identities against the descriptor. + pub fn validate_against(&self, descriptor: &EnvironmentDescriptor) -> Result<()> { + self.validate()?; + if self.inspection.schema != descriptor.inspection_schema { + return err("WorldObservation: inspection must use the descriptor's inspectionSchema"); + } + for view in self.sensory_views.iter().chain(&self.broadcast_views) { + let declared = descriptor.view(&view.view_id).ok_or_else(|| { + crate::scalar::wire_err(format!( + "WorldObservation: view {:?} is not declared by the descriptor", + view.view_id + )) + })?; + view.validate_against(declared, Some(self.boundary))?; + } + for chunk in &self.audio { + let declared = descriptor.audio_stream(&chunk.stream_id).ok_or_else(|| { + crate::scalar::wire_err(format!( + "WorldObservation: audio stream {:?} is not declared by the descriptor", + chunk.stream_id + )) + })?; + chunk.validate_against(declared)?; + } + Ok(()) + } +} + +impl DomainType for WorldObservation { + const TYPE_NAME: &'static str = "WorldObservation"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "WorldObservation")?; + let boundary = f.u64_string("boundary")?; + let world_time = RationalNs::from_json(f.value("worldTime")?)?; + let engine_frame = + crate::scalar::nullable_bounded_string(&mut f, "engineFrame", MAX_ENGINE_FRAME_LEN)?; + let sensory_views = view_list(&mut f, "sensoryViews")?; + let inspection = TypedValue::from_json(f.value("inspection")?)?; + let broadcast_views = view_list(&mut f, "broadcastViews")?; + let audio = audio_list(&mut f, "audio")?; + f.finish()?; + let o = WorldObservation { + boundary, + world_time, + engine_frame, + sensory_views, + inspection, + broadcast_views, + audio, + }; + o.validate()?; + Ok(o) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("boundary", u64_json(self.boundary)), + ("worldTime", self.world_time.to_json()), + ( + "engineFrame", + self.engine_frame.clone().map_or(Value::Null, Value::String), + ), + ( + "sensoryViews", + Value::Array(self.sensory_views.iter().map(ViewRef::to_json).collect()), + ), + ("inspection", self.inspection.to_json()), + ( + "broadcastViews", + Value::Array(self.broadcast_views.iter().map(ViewRef::to_json).collect()), + ), + ( + "audio", + Value::Array( + self.audio + .iter() + .map(crate::media::AudioRef::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.world_time.validate()?; + if let Some(frame) = &self.engine_frame + && frame.chars().count() > MAX_ENGINE_FRAME_LEN + { + return err("WorldObservation: engineFrame is at most 64 characters"); + } + if self.sensory_views.len() > MAX_VIEWS || self.broadcast_views.len() > MAX_VIEWS { + return err("WorldObservation: at most 8 views per list"); + } + require_unique( + self.sensory_views.iter().map(|v| v.view_id.as_str()), + "WorldObservation.sensoryViews", + )?; + require_unique( + self.broadcast_views.iter().map(|v| v.view_id.as_str()), + "WorldObservation.broadcastViews", + )?; + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "WorldObservation.audio", + )?; + for view in self.sensory_views.iter().chain(&self.broadcast_views) { + view.validate()?; + if view.produced_step > self.boundary { + return err("WorldObservation: a view cannot be produced after the boundary"); + } + } + for chunk in &self.audio { + chunk.validate()?; + } + self.inspection.validate() + } +} + +/// `Environment.Initialize` result: boundary 0 with world time zero. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentInitializeResult { + pub descriptor: EnvironmentDescriptor, + pub observation: WorldObservation, +} + +impl DomainType for EnvironmentInitializeResult { + const TYPE_NAME: &'static str = "EnvironmentInitializeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentInitializeResult")?; + let descriptor = EnvironmentDescriptor::from_json(f.value("descriptor")?)?; + let observation = WorldObservation::from_json(f.value("observation")?)?; + f.finish()?; + let r = EnvironmentInitializeResult { + descriptor, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("descriptor", self.descriptor.to_json()), + ("observation", self.observation.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + self.descriptor.validate()?; + self.observation.validate()?; + if self.observation.boundary != 0 { + return err("EnvironmentInitializeResult: the initial observation is boundary 0"); + } + if !self.observation.world_time.is_zero() { + return err("EnvironmentInitializeResult: initial worldTime is zero (0/1)"); + } + self.observation.validate_against(&self.descriptor) + } +} + +/// `Environment.Advance` params: exactly one complete batch. +#[derive(Clone, Debug, PartialEq)] +pub struct AdvanceParams { + pub batch_id: String, + pub controls: Vec, +} + +impl DomainType for AdvanceParams { + const TYPE_NAME: &'static str = "AdvanceParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AdvanceParams")?; + let batch_id = f.id("batchId")?; + let controls = list(&mut f, "controls", 1, MAX_PORTS, PortControl::from_json)?; + f.finish()?; + let p = AdvanceParams { batch_id, controls }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("batchId", self.batch_id.clone().into()), + ( + "controls", + Value::Array(self.controls.iter().map(PortControl::to_json).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.batch_id) { + return err("AdvanceParams: batchId is not a valid id"); + } + if self.controls.is_empty() || self.controls.len() > MAX_PORTS { + return err("AdvanceParams: 1..=4 port controls"); + } + require_unique( + self.controls.iter().map(|c| c.port_id.as_str()), + "AdvanceParams.controls", + )?; + for control in &self.controls { + control.validate()?; + } + Ok(()) + } +} + +/// `Environment.Advance` result. +#[derive(Clone, Debug, PartialEq)] +pub struct StepResult { + pub batch_id: String, + pub applied_from_step: u64, + pub next_step: u64, + pub applied_controls_digest: String, + pub observation: WorldObservation, +} + +impl StepResult { + /// The environment returns exactly boundary `k+1` with world time advanced by exactly one + /// `stepDuration` (workers-v1 section 3, step-v1 section 3 phase B). + pub fn validate_against( + &self, + descriptor: &EnvironmentDescriptor, + previous: &WorldObservation, + ) -> Result<()> { + self.validate()?; + self.observation.validate_against(descriptor)?; + let expected = previous.world_time.checked_add(&descriptor.step_duration)?; + if self.observation.world_time != expected { + return err("StepResult: worldTime must advance by exactly one stepDuration"); + } + if self.observation.boundary != previous.boundary + 1 { + return err("StepResult: the observation must be exactly the next boundary"); + } + Ok(()) + } +} + +impl DomainType for StepResult { + const TYPE_NAME: &'static str = "StepResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StepResult")?; + let batch_id = f.id("batchId")?; + let applied_from_step = f.u64_string("appliedFromStep")?; + let next_step = f.u64_string("nextStep")?; + let applied_controls_digest = f.string("appliedControlsDigest")?.to_owned(); + let observation = WorldObservation::from_json(f.value("observation")?)?; + f.finish()?; + let r = StepResult { + batch_id, + applied_from_step, + next_step, + applied_controls_digest, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("batchId", self.batch_id.clone().into()), + ("appliedFromStep", u64_json(self.applied_from_step)), + ("nextStep", u64_json(self.next_step)), + ( + "appliedControlsDigest", + self.applied_controls_digest.clone().into(), + ), + ("observation", self.observation.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.batch_id) { + return err("StepResult: batchId is not a valid id"); + } + if !is_digest(&self.applied_controls_digest) { + return err("StepResult: appliedControlsDigest must be 64 lowercase hex digits"); + } + if self.next_step != self.applied_from_step + 1 { + return err("StepResult: nextStep must be appliedFromStep + 1; one result is one step"); + } + self.observation.validate()?; + if self.observation.boundary != self.next_step { + return err("StepResult: the observation boundary must be nextStep"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Common worker methods (ipc-v1 section 4, workers-v1 sections 1 and 6) + +/// `Worker.Hello` params. Scope is null. +#[derive(Clone, Debug, PartialEq)] +pub struct HelloParams { + pub session_id: String, + pub expected_worker_id: String, + pub role: Role, + pub supported_majors: Vec, +} + +impl DomainType for HelloParams { + const TYPE_NAME: &'static str = "HelloParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "HelloParams")?; + let session_id = f.id("sessionId")?; + let expected_worker_id = f.id("expectedWorkerId")?; + let role = Role::parse(&enumeration(&mut f, "role", Role::ALL)?)?; + let supported_majors = list( + &mut f, + "supportedMajors", + 1, + MAX_SUPPORTED_MAJORS, + |v| match v.as_u64() { + Some(n) if (1..=65_535).contains(&n) => Ok(n), + _ => err("every supported major must be an integer 1..=65535"), + }, + )?; + f.finish()?; + let p = HelloParams { + session_id, + expected_worker_id, + role, + supported_majors, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("expectedWorkerId", self.expected_worker_id.clone().into()), + ("role", self.role.as_str().into()), + ( + "supportedMajors", + Value::Array( + self.supported_majors + .iter() + .map(|n| Value::from(*n)) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) || !is_id(&self.expected_worker_id) { + return err("HelloParams: sessionId and expectedWorkerId must be valid ids"); + } + if self.supported_majors.is_empty() { + return err("HelloParams: supportedMajors must name at least one major"); + } + let mut seen: Vec = Vec::new(); + for major in &self.supported_majors { + if seen.contains(major) { + return err("HelloParams: supportedMajors must not repeat a major"); + } + seen.push(*major); + } + Ok(()) + } +} + +/// `Worker.Hello` result. v1 selects major 1, minor 0. +#[derive(Clone, Debug, PartialEq)] +pub struct HelloResult { + pub worker_id: String, + pub incarnation_id: String, + pub role: Role, + pub build_digest: String, + pub contract_digest: String, + pub capabilities: Vec, + pub max_agents: u64, + pub max_ports: u64, +} + +impl HelloResult { + /// Required capabilities are agent-step-v1 and world-step-v1 for their roles + /// (ipc-v1 section 4). + pub fn required_capability(role: Role) -> Option<&'static str> { + match role { + Role::Agent => Some("agent-step-v1"), + Role::Environment => Some("world-step-v1"), + Role::Coordinator => None, + } + } + + pub fn has(&self, capability: &str) -> bool { + self.capabilities.iter().any(|c| c == capability) + } +} + +impl DomainType for HelloResult { + const TYPE_NAME: &'static str = "HelloResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "HelloResult")?; + let selected_major = f.int("selectedMajor", 1, 1)?; + let selected_minor = f.int("selectedMinor", 0, 0)?; + debug_assert_eq!((selected_major, selected_minor), (1, 0)); + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let role = Role::parse(&enumeration(&mut f, "role", Role::ALL)?)?; + let build_digest = f.string("buildDigest")?.to_owned(); + let contract_digest = f.string("contractDigest")?.to_owned(); + let capabilities = id_list(&mut f, "capabilities", 0, MAX_CAPABILITIES)?; + let (max_agents, max_ports) = { + let v = f.value("limits")?; + let mut l = Fields::new(v, "HelloResult.limits")?; + let max_agents = l.int("maxAgents", 1, MAX_AGENTS as u64)?; + let max_ports = l.int("maxPorts", 1, MAX_PORTS as u64)?; + l.finish()?; + (max_agents, max_ports) + }; + f.finish()?; + let r = HelloResult { + worker_id, + incarnation_id, + role, + build_digest, + contract_digest, + capabilities, + max_agents, + max_ports, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("selectedMajor", Value::from(1u64)), + ("selectedMinor", Value::from(0u64)), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("role", self.role.as_str().into()), + ("buildDigest", self.build_digest.clone().into()), + ("contractDigest", self.contract_digest.clone().into()), + ( + "capabilities", + Value::Array(self.capabilities.iter().map(|c| c.clone().into()).collect()), + ), + ( + "limits", + obj(vec![ + ("maxAgents", Value::from(self.max_agents)), + ("maxPorts", Value::from(self.max_ports)), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("HelloResult: workerId and incarnationId must be valid ids"); + } + if !is_digest(&self.build_digest) || !is_digest(&self.contract_digest) { + return err("HelloResult: buildDigest and contractDigest must be 64 hex digits"); + } + require_unique( + self.capabilities.iter().map(String::as_str), + "HelloResult.capabilities", + )?; + if let Some(required) = HelloResult::required_capability(self.role) + && !self.has(required) + { + return err(format!( + "HelloResult: a {} worker must advertise {required}", + self.role.as_str() + )); + } + if !(1..=MAX_AGENTS as u64).contains(&self.max_agents) + || !(1..=MAX_PORTS as u64).contains(&self.max_ports) + { + return err("HelloResult: the first composition allows at most 4 agents and 4 ports"); + } + Ok(()) + } +} + +/// `Worker.Status` result. +#[derive(Clone, Debug, PartialEq)] +pub struct StatusResult { + pub state: WorkerState, + pub current_scope: Option, + pub active_request_id: Option, + pub last_completed_request_id: Option, + pub last_batch_id: Option, + pub progress_counter: u64, +} + +impl DomainType for StatusResult { + const TYPE_NAME: &'static str = "StatusResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StatusResult")?; + let state = WorkerState::parse(&enumeration(&mut f, "state", WorkerState::ALL)?)?; + let current_scope = crate::scalar::Scope::nullable_from_json(f.value("currentScope")?)?; + let active_request_id = DomainRequestId::read_nullable(&mut f, "activeRequestId")?; + let last_completed_request_id = + DomainRequestId::read_nullable(&mut f, "lastCompletedRequestId")?; + let last_batch_id = f.nullable_id("lastBatchId")?; + let progress_counter = f.u64_string("progressCounter")?; + f.finish()?; + let r = StatusResult { + state, + current_scope, + active_request_id, + last_completed_request_id, + last_batch_id, + progress_counter, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("state", self.state.as_str().into()), + ( + "currentScope", + crate::scalar::Scope::nullable_to_json(self.current_scope.as_ref()), + ), + ( + "activeRequestId", + self.active_request_id + .as_ref() + .map_or(Value::Null, DomainRequestId::to_json), + ), + ( + "lastCompletedRequestId", + self.last_completed_request_id + .as_ref() + .map_or(Value::Null, DomainRequestId::to_json), + ), + ( + "lastBatchId", + self.last_batch_id + .clone() + .map_or(Value::Null, Value::String), + ), + ("progressCounter", u64_json(self.progress_counter)), + ]) + } + + fn validate(&self) -> Result<()> { + if let Some(scope) = &self.current_scope { + scope.validate()?; + } + if self.state == WorkerState::Uninitialized && self.current_scope.is_some() { + return err("StatusResult: an uninitialized worker has a null currentScope"); + } + if let Some(batch) = &self.last_batch_id + && !is_id(batch) + { + return err("StatusResult: lastBatchId is not a valid id"); + } + Ok(()) + } +} + +/// `Worker.Acknowledge` params: 1..=16 retained lifecycle replies. +#[derive(Clone, Debug, PartialEq)] +pub struct AcknowledgeParams { + pub request_ids: Vec, +} + +impl DomainType for AcknowledgeParams { + const TYPE_NAME: &'static str = "AcknowledgeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AcknowledgeParams")?; + let request_ids = list(&mut f, "requestIds", 1, MAX_ACKNOWLEDGE, |v| { + match v.as_str() { + Some(s) => DomainRequestId::parse(s), + None => err("every requestId must be a string"), + } + })?; + f.finish()?; + let p = AcknowledgeParams { request_ids }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![( + "requestIds", + Value::Array( + self.request_ids + .iter() + .map(DomainRequestId::to_json) + .collect(), + ), + )]) + } + + fn validate(&self) -> Result<()> { + if self.request_ids.is_empty() || self.request_ids.len() > MAX_ACKNOWLEDGE { + return err("AcknowledgeParams: requestIds carries 1..=16 ids"); + } + require_unique( + self.request_ids.iter().map(DomainRequestId::as_str), + "AcknowledgeParams.requestIds", + ) + } +} + +/// `Worker.Acknowledge` result. Already released or unknown ids are ignored, so the +/// acknowledged list is a subset of the request. +#[derive(Clone, Debug, PartialEq)] +pub struct AcknowledgeResult { + pub acknowledged: Vec, +} + +impl AcknowledgeResult { + pub fn validate_against(&self, params: &AcknowledgeParams) -> Result<()> { + self.validate()?; + for id in &self.acknowledged { + if !params.request_ids.contains(id) { + return err(format!( + "AcknowledgeResult: {} was not in the request", + id.as_str() + )); + } + } + Ok(()) + } +} + +impl DomainType for AcknowledgeResult { + const TYPE_NAME: &'static str = "AcknowledgeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AcknowledgeResult")?; + let acknowledged = list(&mut f, "acknowledged", 0, MAX_ACKNOWLEDGE, |v| { + match v.as_str() { + Some(s) => DomainRequestId::parse(s), + None => err("every acknowledged id must be a string"), + } + })?; + f.finish()?; + let r = AcknowledgeResult { acknowledged }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![( + "acknowledged", + Value::Array( + self.acknowledged + .iter() + .map(DomainRequestId::to_json) + .collect(), + ), + )]) + } + + fn validate(&self) -> Result<()> { + if self.acknowledged.len() > MAX_ACKNOWLEDGE { + return err("AcknowledgeResult: at most 16 acknowledged ids"); + } + require_unique( + self.acknowledged.iter().map(DomainRequestId::as_str), + "AcknowledgeResult.acknowledged", + ) + } +} + +/// `Worker.Shutdown` params. +#[derive(Clone, Debug, PartialEq)] +pub struct ShutdownParams { + pub reason: String, +} + +impl DomainType for ShutdownParams { + const TYPE_NAME: &'static str = "ShutdownParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ShutdownParams")?; + let reason = f.id("reason")?; + f.finish()?; + let p = ShutdownParams { reason }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("reason", self.reason.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.reason) { + return err("ShutdownParams: reason is not a valid id"); + } + Ok(()) + } +} + +/// `Worker.Shutdown` result. It does not imply saved state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShutdownResult; + +impl DomainType for ShutdownResult { + const TYPE_NAME: &'static str = "ShutdownResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ShutdownResult")?; + constant_true(&mut f, "stopping")?; + f.finish()?; + Ok(ShutdownResult) + } + + fn to_json(&self) -> Value { + obj(vec![("stopping", Value::Bool(true))]) + } + + fn validate(&self) -> Result<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Task outputs (workers-v1 section 4) + +/// A task event: `{id, kindId, sourceStep, agentId, payload}`, deterministic in order. +#[derive(Clone, Debug, PartialEq)] +pub struct TaskEvent { + pub id: String, + pub kind_id: String, + pub source_step: u64, + pub agent_id: Option, + pub payload: TypedValue, +} + +impl DomainType for TaskEvent { + const TYPE_NAME: &'static str = "TaskEvent"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TaskEvent")?; + let id = f.id("id")?; + let kind_id = f.id("kindId")?; + let source_step = f.u64_string("sourceStep")?; + let agent_id = f.nullable_id("agentId")?; + let payload = TypedValue::from_json(f.value("payload")?)?; + f.finish()?; + let e = TaskEvent { + id, + kind_id, + source_step, + agent_id, + payload, + }; + e.validate()?; + Ok(e) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("kindId", self.kind_id.clone().into()), + ("sourceStep", u64_json(self.source_step)), + ( + "agentId", + self.agent_id.clone().map_or(Value::Null, Value::String), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) || !is_id(&self.kind_id) { + return err("TaskEvent: id and kindId must be valid ids"); + } + if let Some(agent_id) = &self.agent_id + && !is_id(agent_id) + { + return err("TaskEvent: agentId is not a valid id"); + } + self.payload.validate() + } +} + +/// `episodeRequest`: null, or a terminal request the coordinator may act on. +#[derive(Clone, Debug, PartialEq)] +pub struct EpisodeRequest { + pub reason: String, + pub outcome: TypedValue, +} + +impl DomainType for EpisodeRequest { + const TYPE_NAME: &'static str = "EpisodeRequest"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EpisodeRequest")?; + constant(&mut f, "kind", "terminal")?; + let reason = f.id("reason")?; + let outcome = TypedValue::from_json(f.value("outcome")?)?; + f.finish()?; + let r = EpisodeRequest { reason, outcome }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("kind", "terminal".into()), + ("reason", self.reason.clone().into()), + ("outcome", self.outcome.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.reason) { + return err("EpisodeRequest: reason is not a valid id"); + } + self.outcome.validate() + } +}