Merge feat/sf-contract-01: the fly-session-types crate, so the session slice builds on the shared contract types
This commit is contained in:
commit
825b584320
41 changed files with 20716 additions and 4 deletions
14
services/flysim/Cargo.lock
generated
14
services/flysim/Cargo.lock
generated
|
|
@ -420,15 +420,23 @@ dependencies = [
|
||||||
name = "fly-session"
|
name = "fly-session"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fly-session-types",
|
||||||
"flybus",
|
"flybus",
|
||||||
"ryu-js",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fly-session-types"
|
||||||
|
version = "0.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"flybus",
|
||||||
|
"ryu-js",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flybrain-core"
|
name = "flybrain-core"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
|
"crates/fly-session",
|
||||||
|
"crates/fly-session-types",
|
||||||
"crates/flybrain-core",
|
"crates/flybrain-core",
|
||||||
"crates/flybrain-gb",
|
"crates/flybrain-gb",
|
||||||
"crates/flybus",
|
"crates/flybus",
|
||||||
"crates/fly-session",
|
|
||||||
"crates/flysim",
|
"crates/flysim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
16
services/flysim/crates/fly-session-types/Cargo.toml
Normal file
16
services/flysim/crates/fly-session-types/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
103
services/flysim/crates/fly-session-types/README.md
Normal file
103
services/flysim/crates/fly-session-types/README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# fly-session-types
|
||||||
|
|
||||||
|
The executable schemas of the session framework: domain scalars, closed enums, method
|
||||||
|
payloads, canonical JSON, canonical digests and the step trace format.
|
||||||
|
|
||||||
|
This crate is CONTRACT-01 of
|
||||||
|
[`docs/design/session-framework/implementation.md`](../../../../docs/design/session-framework/implementation.md).
|
||||||
|
It holds no transport, no worker, no coordinator and no store; it never opens a socket or a
|
||||||
|
file other than its own fixtures. The bus owns the wire
|
||||||
|
([`flybus`](../flybus)), and this crate owns what the messages mean.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Module | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `scalar` | `Scope`, `RationalNs`, `SchemaRef`, `TypedValue`, the `DomainType` trait, and `BusCallId` / `DomainRequestId` / `ArtifactIdentity` / `OwnerToken` |
|
||||||
|
| `canonical` | RFC 8785 canonical JSON, SHA-256 digests, `OperationKey`, canonical bodies, the 64-KiB envelope check |
|
||||||
|
| `rpc` | `SessionRpcRequest`, `SessionRpcSuccess`, `SessionRpcFailure`, `ErrorCode`, `MutationCertainty` |
|
||||||
|
| `workers` | The closed enums and every Agent/Environment/Worker method payload of workers-v1 |
|
||||||
|
| `media` | `ViewDescriptor`, `ViewRef`, `AudioDescriptor`, `AudioRef` and the `State.*` payloads |
|
||||||
|
| `publishing` | `SessionDescriptor` and `CommittedSnapshot` |
|
||||||
|
| `trace` | `TraceBehaviour`, `TraceOperational`, `TransitionTrace` and the behaviour comparator |
|
||||||
|
| `schema` | The canonical schema set and `contract_digest()` |
|
||||||
|
| `seed` | `seed-derivation-v1` |
|
||||||
|
| `checkpoint` | The `FLYSESS1` envelope layout |
|
||||||
|
| `fixtures` | Loading `fixtures/`, shared with `packages/session-types` |
|
||||||
|
|
||||||
|
`Id`, `U64` and `Digest` are the bus encodings: `scalar` calls into `flybus::wire` instead of
|
||||||
|
restating them, and `tests/encodings.rs` pins that the two agree for every edge case.
|
||||||
|
|
||||||
|
## Reading and validating
|
||||||
|
|
||||||
|
Every type implements `DomainType`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use fly_session_types::scalar::{DomainType, Scope};
|
||||||
|
|
||||||
|
let scope = Scope::from_json(&value)?; // reads, refusing unknown fields, then validates
|
||||||
|
scope.validate()?; // the cross-field rules, re-runnable
|
||||||
|
let json = scope.to_json(); // the canonical shape
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules that need another value in hand are separate, because a payload cannot check them alone:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
control.validate_against(&port.controls)?; // complete batch, descriptor order, ranges
|
||||||
|
input.validate_against(&descriptor.views)?; // max(0, boundary - observationDelaySteps)
|
||||||
|
result.validate_against(&descriptor, &previous)?; // exactly one stepDuration of world time
|
||||||
|
snapshot.validate_against(&session_descriptor)?; // revision, agent set, assigned ports
|
||||||
|
telemetry.validate_against_roles(&profile_roles)?; // rates in profile-defined order
|
||||||
|
```
|
||||||
|
|
||||||
|
## Digests
|
||||||
|
|
||||||
|
- `contract_digest()` is the SHA-256 of the canonical schema set (`schema::schema_set()`),
|
||||||
|
which is a declaration: type names, JSON field names, kinds, bounds and closed enums.
|
||||||
|
Reformatting this crate cannot change it; changing a field or a bound does.
|
||||||
|
- `canonical::body_digest(method, scope, params)` is the comparison ipc-v1 section 5 uses to
|
||||||
|
tell a safe replay from a `CONFLICT`. It refuses a body that carries a bus identity.
|
||||||
|
- `OperationKey` is `(sessionId, epoch, step, method, workerId)`, and deliberately not the
|
||||||
|
request id: a changed id for an existing key is the conflict to detect.
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
`fixtures/` is loaded by these tests and by `packages/session-types`, so a case is written
|
||||||
|
once and holds both languages to it.
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `valid.json` | Payloads every implementation accepts, with their canonical JSON and digest |
|
||||||
|
| `invalid.json` | Payloads every implementation refuses, each with the rule it breaks |
|
||||||
|
| `raw.json` | Byte sequences refused before validation: duplicate keys, invalid UTF-8, `NaN`, trailing data |
|
||||||
|
| `generated.json` | Recipes for payloads too large to store: the 32-KiB and 64-KiB boundaries, 512-code-point messages |
|
||||||
|
| `boundaries.json` | The `U64` decimal-string and double boundaries |
|
||||||
|
| `rational.json` | Checked rational arithmetic and the 16, 17, 17 tick accumulator |
|
||||||
|
| `identities.json` | Which of the four identity types accepts which spelling |
|
||||||
|
| `descriptor-checks.json` | Rules that need a descriptor: batches, delays, byte shapes, descriptor agreement |
|
||||||
|
| `operations.json` | Operation keys, canonical bodies and the pairs that are or are not the same operation |
|
||||||
|
| `traces.json` | A baseline transition and the variants that must or must not compare equal |
|
||||||
|
| `schema-set.json`, `contract-digest.json` | The canonical schema set and its digest |
|
||||||
|
| `seed-vectors.json` | `seed-derivation-v1` test vectors |
|
||||||
|
| `checkpoint-envelope.json` | One `FLYSESS1` envelope, its layout and the corruptions a reader refuses |
|
||||||
|
|
||||||
|
The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields
|
||||||
|
of `valid.json`, the digests in `operations.json`, `seed-vectors.json` and
|
||||||
|
`checkpoint-envelope.json`) come from
|
||||||
|
`cargo run -p fly-session-types --example update_fixtures`;
|
||||||
|
`tests/schema_set.rs` fails if the checked-in files are stale.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p fly-session-types
|
||||||
|
cargo clippy -p fly-session-types --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bounds this crate chose
|
||||||
|
|
||||||
|
Every bound in the schema set names its source. Six are marked `crate` because no document
|
||||||
|
states them: `maxAudioStreams` (8), `maxCapabilities` (32), `maxSupportedMajors` (8),
|
||||||
|
`maxSupportedStimuli` (64), `maxAssets` (64) and `maxSnapshotEvents` (64). They exist so an
|
||||||
|
unbounded array cannot fill an envelope, and they are in the digest, so widening one is a
|
||||||
|
contract change rather than a quiet edit.
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
//! Regenerates the derived fixture files.
|
||||||
|
//!
|
||||||
|
//! `cargo run -p fly-session-types --example update_fixtures`. `tests/fixtures_current.rs`
|
||||||
|
//! fails if the checked-in files differ from what this writes, so the digests in the
|
||||||
|
//! fixtures can never drift from the code that produced them.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use fly_session_types::scalar::{DomainType, Scope};
|
||||||
|
use fly_session_types::{canonical, checkpoint, fixtures, schema, seed};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dir = fixtures::dir();
|
||||||
|
for (name, contents) in derived() {
|
||||||
|
let path = dir.join(&name);
|
||||||
|
std::fs::write(&path, contents).expect("write fixture");
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every derived fixture, as `(file name, exact bytes)`.
|
||||||
|
pub fn derived() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
("schema-set.json".to_owned(), schema_set()),
|
||||||
|
("contract-digest.json".to_owned(), contract_digest()),
|
||||||
|
("valid.json".to_owned(), valid()),
|
||||||
|
("operations.json".to_owned(), operations()),
|
||||||
|
("seed-vectors.json".to_owned(), seed_vectors()),
|
||||||
|
("checkpoint-envelope.json".to_owned(), checkpoint_envelope()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(value: &Value) -> String {
|
||||||
|
let mut text = serde_json::to_string_pretty(value).expect("serializable");
|
||||||
|
text.push('\n');
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_set() -> String {
|
||||||
|
// The rendered set is itself canonical JSON, so the file the TypeScript package hashes is
|
||||||
|
// byte for byte what the digest was taken over.
|
||||||
|
let mut text = schema::schema_set_json().expect("canonicalizable");
|
||||||
|
text.push('\n');
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contract_digest() -> String {
|
||||||
|
let set = schema::schema_set_json().expect("canonicalizable");
|
||||||
|
write(&json!({
|
||||||
|
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
|
||||||
|
"contractDigest": schema::contract_digest(),
|
||||||
|
"schemaSetVersion": schema::SCHEMA_SET_VERSION,
|
||||||
|
"schemaSetBytes": set.len(),
|
||||||
|
"types": schema::SCHEMAS.len(),
|
||||||
|
"enums": schema::ENUMS.len(),
|
||||||
|
"limits": schema::LIMITS.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid() -> String {
|
||||||
|
let mut file = fixtures::load("valid.json").expect("valid.json");
|
||||||
|
let cases = file
|
||||||
|
.get_mut("cases")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.expect("cases");
|
||||||
|
for case in cases.iter_mut() {
|
||||||
|
let value = case.get("value").expect("value").clone();
|
||||||
|
let canonical = canonical::canonicalize(&value).expect("canonicalizable");
|
||||||
|
let digest = canonical::sha256_hex(canonical.as_bytes());
|
||||||
|
let map = case.as_object_mut().expect("case object");
|
||||||
|
map.insert("canonical".to_owned(), Value::String(canonical));
|
||||||
|
map.insert("digest".to_owned(), Value::String(digest));
|
||||||
|
}
|
||||||
|
write(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operations() -> String {
|
||||||
|
let mut file = fixtures::load("operations.json").expect("operations.json");
|
||||||
|
let scope_of = |case: &Value| -> Option<Scope> {
|
||||||
|
match case.get("scope") {
|
||||||
|
Some(Value::Null) | None => None,
|
||||||
|
Some(v) => Some(Scope::from_json(v).expect("scope")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for key in file
|
||||||
|
.get_mut("keys")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.expect("keys")
|
||||||
|
{
|
||||||
|
let scope = scope_of(key).expect("an operation key has a scope");
|
||||||
|
let method = key.get("method").and_then(Value::as_str).expect("method");
|
||||||
|
let worker = key.get("workerId").and_then(Value::as_str).expect("workerId");
|
||||||
|
let digest = canonical::OperationKey::new(scope, method, worker)
|
||||||
|
.expect("valid key")
|
||||||
|
.digest()
|
||||||
|
.expect("digest");
|
||||||
|
key.as_object_mut()
|
||||||
|
.expect("object")
|
||||||
|
.insert("digest".to_owned(), Value::String(digest));
|
||||||
|
}
|
||||||
|
for body in file
|
||||||
|
.get_mut("bodies")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.expect("bodies")
|
||||||
|
{
|
||||||
|
let scope = scope_of(body);
|
||||||
|
let method = body.get("method").and_then(Value::as_str).expect("method");
|
||||||
|
let params = body.get("params").expect("params").clone();
|
||||||
|
let digest =
|
||||||
|
canonical::body_digest(method, scope.as_ref(), ¶ms).expect("canonical body");
|
||||||
|
body.as_object_mut()
|
||||||
|
.expect("object")
|
||||||
|
.insert("digest".to_owned(), Value::String(digest));
|
||||||
|
}
|
||||||
|
write(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_vectors() -> String {
|
||||||
|
let master_seeds: [u64; 5] = [0, 1, 42, 9_223_372_036_854_775_808, u64::MAX];
|
||||||
|
let agents = ["fly-a", "fly-b", "fly-c", "fly-d"];
|
||||||
|
let mut vectors = Vec::new();
|
||||||
|
for master in master_seeds {
|
||||||
|
for agent in agents {
|
||||||
|
let material = seed::material(master, agent).expect("material");
|
||||||
|
vectors.push(json!({
|
||||||
|
"masterSeed": master.to_string(),
|
||||||
|
"agentId": agent,
|
||||||
|
"material": String::from_utf8(material).expect("utf-8"),
|
||||||
|
"materialDigest": seed::material_digest(master, agent).expect("digest"),
|
||||||
|
"seed": seed::agent_seed(master, agent).expect("seed"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let composition: Vec<Value> = seed::composition_seeds(
|
||||||
|
42,
|
||||||
|
&agents.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.expect("composition")
|
||||||
|
.into_iter()
|
||||||
|
.map(Value::from)
|
||||||
|
.collect();
|
||||||
|
write(&json!({
|
||||||
|
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
|
||||||
|
"algorithm": seed::ALGORITHM,
|
||||||
|
"prefix": seed::PREFIX,
|
||||||
|
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
|
||||||
|
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
|
||||||
|
"vectors": vectors,
|
||||||
|
"composition": {
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentIds": agents,
|
||||||
|
"seeds": composition,
|
||||||
|
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids",
|
||||||
|
},
|
||||||
|
"invalid": [
|
||||||
|
{"masterSeed": "0", "agentId": "Fly-A", "reason": "an agent id is an Id: lowercase"},
|
||||||
|
{"masterSeed": "0", "agentId": "", "reason": "an agent id is 1..=64 characters"},
|
||||||
|
{"masterSeed": "0", "agentIds": ["fly-a", "fly-a"],
|
||||||
|
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkpoint_envelope() -> String {
|
||||||
|
let scope = Scope::new("demo", "epoch-1", 42).expect("scope");
|
||||||
|
let manifest = json!({
|
||||||
|
"envelopeVersion": checkpoint::VERSION,
|
||||||
|
"checkpointId": "ckpt-1",
|
||||||
|
"sourceScope": scope.to_json(),
|
||||||
|
"episodeId": "episode-1",
|
||||||
|
"worldTime": {"numerator": "700000000", "denominator": "1"},
|
||||||
|
"schedulerId": "lockstep-v1",
|
||||||
|
"compositionDigest": canonical::sha256_hex(b"composition"),
|
||||||
|
"portMap": [{"portId": "port-1", "agentId": "fly-a"}],
|
||||||
|
"compatibility": {
|
||||||
|
"backendDigest": canonical::sha256_hex(b"backend"),
|
||||||
|
"contentDigest": canonical::sha256_hex(b"content"),
|
||||||
|
"patchDigest": canonical::sha256_hex(b"patch"),
|
||||||
|
"controllerDigest": canonical::sha256_hex(b"controller"),
|
||||||
|
"parserDigest": canonical::sha256_hex(b"parser"),
|
||||||
|
"stateFormatId": "flysess-1",
|
||||||
|
},
|
||||||
|
"agents": [{
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": canonical::sha256_hex(b"profile"),
|
||||||
|
"datasetDigest": canonical::sha256_hex(b"fafb-v783"),
|
||||||
|
"modelVersion": "lif-1ms-f64-v2",
|
||||||
|
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
|
||||||
|
"seed": seed::agent_seed(42, "fly-a").expect("seed"),
|
||||||
|
"brainTicks": "2534",
|
||||||
|
"remainder": {"numerator": "1000000", "denominator": "3"},
|
||||||
|
"payload": "agent-fly-a",
|
||||||
|
}],
|
||||||
|
"coordinator": {
|
||||||
|
"taskLedger": "task-ledger",
|
||||||
|
"priorInspection": "prior-inspection",
|
||||||
|
"executorState": [{"agentId": "fly-a", "payload": "executor-fly-a"}],
|
||||||
|
"admissionState": null,
|
||||||
|
"eventWatermarks": {"lastEventId": "evt-1", "lastOrdinal": "7"},
|
||||||
|
},
|
||||||
|
"helperState": [],
|
||||||
|
"payloads": payload_table(),
|
||||||
|
});
|
||||||
|
let bytes = checkpoint::encode(&manifest, &payloads()).expect("encode");
|
||||||
|
let envelope = checkpoint::decode(&bytes).expect("decode");
|
||||||
|
let entries: Vec<Value> = envelope
|
||||||
|
.layout
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
json!({
|
||||||
|
"name": entry.name,
|
||||||
|
"offset": entry.offset.to_string(),
|
||||||
|
"byteLength": entry.byte_length.to_string(),
|
||||||
|
"digest": checkpoint::hex(&entry.digest),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let first_payload = envelope.layout.entries[0].offset;
|
||||||
|
write(&json!({
|
||||||
|
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
|
||||||
|
"magic": "FLYSESS1",
|
||||||
|
"footerMagic": "FLYSESSF",
|
||||||
|
"version": checkpoint::VERSION,
|
||||||
|
"manifest": manifest,
|
||||||
|
"payloads": payloads()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, bytes)| json!({"name": name, "base64": fixtures::encode_base64(bytes)}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"envelope": {
|
||||||
|
"base64": fixtures::encode_base64(&bytes),
|
||||||
|
"byteLength": bytes.len(),
|
||||||
|
"layout": {
|
||||||
|
"headerBytes": checkpoint::HEADER_BYTES,
|
||||||
|
"manifestOffset": envelope.layout.manifest_offset.to_string(),
|
||||||
|
"manifestBytes": envelope.layout.manifest_bytes,
|
||||||
|
"tableOffset": envelope.layout.table_offset.to_string(),
|
||||||
|
"tableEntryBytes": checkpoint::TABLE_ENTRY_BYTES,
|
||||||
|
"entries": entries,
|
||||||
|
"footerOffset": envelope.layout.footer_offset.to_string(),
|
||||||
|
"footerBytes": checkpoint::FOOTER_BYTES,
|
||||||
|
"totalBytes": envelope.layout.total_bytes.to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"corruption": [
|
||||||
|
{"name": "a flipped magic byte", "offset": 0, "reason": "wrong magic"},
|
||||||
|
{"name": "an unsupported version", "offset": 8, "reason": "unsupported version"},
|
||||||
|
{"name": "a flipped manifest byte", "offset": checkpoint::HEADER_BYTES,
|
||||||
|
"reason": "the footer digest covers the manifest"},
|
||||||
|
{"name": "a flipped payload byte", "offset": first_payload,
|
||||||
|
"reason": "every payload carries its own digest"},
|
||||||
|
{"name": "a flipped footer digest byte", "offset": bytes.len() - 40,
|
||||||
|
"reason": "the footer digest must match the contents"},
|
||||||
|
{"name": "a flipped footer magic byte", "offset": bytes.len() - 8,
|
||||||
|
"reason": "a truncated file cannot look complete"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payloads() -> Vec<(String, Vec<u8>)> {
|
||||||
|
vec![
|
||||||
|
("agent-fly-a".to_owned(), b"agent state bytes".to_vec()),
|
||||||
|
("executor-fly-a".to_owned(), b"executor state".to_vec()),
|
||||||
|
("task-ledger".to_owned(), b"{\"rank\":10}".to_vec()),
|
||||||
|
("prior-inspection".to_owned(), b"{\"map\":40}".to_vec()),
|
||||||
|
("world".to_owned(), vec![0u8; 64]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_table() -> Value {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (name, bytes) in payloads() {
|
||||||
|
let mut entry = Map::new();
|
||||||
|
entry.insert("name".to_owned(), Value::String(name));
|
||||||
|
entry.insert(
|
||||||
|
"byteLength".to_owned(),
|
||||||
|
Value::String(bytes.len().to_string()),
|
||||||
|
);
|
||||||
|
entry.insert(
|
||||||
|
"digest".to_owned(),
|
||||||
|
Value::String(canonical::sha256_hex(&bytes)),
|
||||||
|
);
|
||||||
|
out.push(Value::Object(entry));
|
||||||
|
}
|
||||||
|
// A BTreeMap would sort the payload names; the table order is the write order, which is
|
||||||
|
// what the envelope records.
|
||||||
|
let _: BTreeMap<(), ()> = BTreeMap::new();
|
||||||
|
Value::Array(out)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
{
|
||||||
|
"description": "The U64 decimal-string and double boundaries, shared by both languages.",
|
||||||
|
"u64": [
|
||||||
|
{
|
||||||
|
"text": "0",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "zero is \"0\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "1",
|
||||||
|
"accept": true,
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "18446744073709551615",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "the U64 maximum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "18446744073709551616",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "one past the maximum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "184467440737095516150",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "far past the maximum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "00",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "no leading zeros"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "01",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "no leading zeros"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "empty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "-1",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "unsigned"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "+1",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "no sign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "1.0",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "integers only"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "1e3",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "decimal digits only"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " 1",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "no whitespace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "1 ",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "no whitespace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "0x10",
|
||||||
|
"accept": false,
|
||||||
|
"reason": "decimal only"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "9007199254740993",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "a U64 string keeps precision a double would lose"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"doubles": [
|
||||||
|
{
|
||||||
|
"value": 0.0,
|
||||||
|
"canonical": "0",
|
||||||
|
"accept": true,
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": -0.0,
|
||||||
|
"canonical": "0",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "JSON.stringify prints negative zero as 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1.0,
|
||||||
|
"canonical": "1",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "an integral double prints without a fraction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 0.1,
|
||||||
|
"canonical": "0.1",
|
||||||
|
"accept": true,
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1e+21,
|
||||||
|
"canonical": "1e+21",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "ECMAScript switches to exponent notation at 1e21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1e-07,
|
||||||
|
"canonical": "1e-7",
|
||||||
|
"accept": true,
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 5e-324,
|
||||||
|
"canonical": "5e-324",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "the smallest subnormal double"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1.7976931348623157e+308,
|
||||||
|
"canonical": "1.7976931348623157e+308",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "the largest finite double"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 9007199254740991,
|
||||||
|
"canonical": "9007199254740991",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "the largest exactly representable integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 9007199254740993,
|
||||||
|
"canonical": null,
|
||||||
|
"accept": false,
|
||||||
|
"reason": "past the exact integer range, canonical JSON refuses it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 0.30000000000000004,
|
||||||
|
"canonical": "0.30000000000000004",
|
||||||
|
"accept": true,
|
||||||
|
"reason": "shortest round-tripping form, not a rounded one"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
{
|
||||||
|
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
|
||||||
|
"magic": "FLYSESS1",
|
||||||
|
"footerMagic": "FLYSESSF",
|
||||||
|
"version": 1,
|
||||||
|
"manifest": {
|
||||||
|
"envelopeVersion": 1,
|
||||||
|
"checkpointId": "ckpt-1",
|
||||||
|
"sourceScope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "42"
|
||||||
|
},
|
||||||
|
"episodeId": "episode-1",
|
||||||
|
"worldTime": {
|
||||||
|
"numerator": "700000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"schedulerId": "lockstep-v1",
|
||||||
|
"compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0",
|
||||||
|
"portMap": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"agentId": "fly-a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be",
|
||||||
|
"contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
|
||||||
|
"patchDigest": "a4895eb44afc336fecbba6e520cd67e178dace0276655d102fceffa8e5f70570",
|
||||||
|
"controllerDigest": "c1472135b14c77c8bef98e73f70208325fa0dcf1e6bd668ae9b31a9cea295fe7",
|
||||||
|
"parserDigest": "b17d45121150928f2146af49e195eff1eef5d67325be273a733fb74acadaa342",
|
||||||
|
"stateFormatId": "flysess-1"
|
||||||
|
},
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||||
|
"modelVersion": "lif-1ms-f64-v2",
|
||||||
|
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
|
||||||
|
"seed": -184946063,
|
||||||
|
"brainTicks": "2534",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"payload": "agent-fly-a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"coordinator": {
|
||||||
|
"taskLedger": "task-ledger",
|
||||||
|
"priorInspection": "prior-inspection",
|
||||||
|
"executorState": [
|
||||||
|
{
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"payload": "executor-fly-a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"admissionState": null,
|
||||||
|
"eventWatermarks": {
|
||||||
|
"lastEventId": "evt-1",
|
||||||
|
"lastOrdinal": "7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"helperState": [],
|
||||||
|
"payloads": [
|
||||||
|
{
|
||||||
|
"name": "agent-fly-a",
|
||||||
|
"byteLength": "17",
|
||||||
|
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor-fly-a",
|
||||||
|
"byteLength": "14",
|
||||||
|
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "task-ledger",
|
||||||
|
"byteLength": "11",
|
||||||
|
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prior-inspection",
|
||||||
|
"byteLength": "10",
|
||||||
|
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world",
|
||||||
|
"byteLength": "64",
|
||||||
|
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"payloads": [
|
||||||
|
{
|
||||||
|
"name": "agent-fly-a",
|
||||||
|
"base64": "YWdlbnQgc3RhdGUgYnl0ZXM="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor-fly-a",
|
||||||
|
"base64": "ZXhlY3V0b3Igc3RhdGU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "task-ledger",
|
||||||
|
"base64": "eyJyYW5rIjoxMH0="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prior-inspection",
|
||||||
|
"base64": "eyJtYXAiOjQwfQ=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world",
|
||||||
|
"base64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"envelope": {
|
||||||
|
"base64": "RkxZU0VTUzEBAAAAIAAAAAAIAAAFAAAAIAgAAAAAAAB7ImFnZW50cyI6W3siYWdlbnRJZCI6ImZseS1hIiwiYnJhaW5UaWNrcyI6IjI1MzQiLCJkYXRhc2V0RGlnZXN0IjoiNmMwYWYxZjA3ODRlZjYzYTM5M2VlNzdkNjE0ZTgyNDZjNjI1MDUxMzYwZjNmMWE0ODgzODM3NGM1ZDM1NWI1MiIsIm1vZGVsVmVyc2lvbiI6ImxpZi0xbXMtZjY0LXYyIiwicGF5bG9hZCI6ImFnZW50LWZseS1hIiwicGxhc3RpY2l0eVZlcnNpb24iOiJmbHkta2MtbWJvbi1yc3RkcC12MiIsInByb2ZpbGVEaWdlc3QiOiIxOTAwZWFiNmMwMjg0ODNkNzEyNjU5OWVlNmY1MGRlMGQyNzkwN2I1YzY1ZmE5MDUyNDU4MGI0YjBmOTg1MmIwIiwicmVtYWluZGVyIjp7ImRlbm9taW5hdG9yIjoiMyIsIm51bWVyYXRvciI6IjEwMDAwMDAifSwic2VlZCI6LTE4NDk0NjA2M31dLCJjaGVja3BvaW50SWQiOiJja3B0LTEiLCJjb21wYXRpYmlsaXR5Ijp7ImJhY2tlbmREaWdlc3QiOiIxMGUwOGE0MTllODUwZWJhMWViYmExOGZkZDI4ZWI3ZWMxYjdlOGJhYTliY2MzYjk3M2UyYjg4OTFlYzcyNmJlIiwiY29udGVudERpZ2VzdCI6ImVkNzAwMmI0MzllOWFjODQ1ZjIyMzU3ZDgyMmJhYzE0NDQ3MzBmYmRiNjAxNmQzZWM5NDMyMjk3YjllYzlmNzMiLCJjb250cm9sbGVyRGlnZXN0IjoiYzE0NzIxMzViMTRjNzdjOGJlZjk4ZTczZjcwMjA4MzI1ZmEwZGNmMWU2YmQ2NjhhZTliMzFhOWNlYTI5NWZlNyIsInBhcnNlckRpZ2VzdCI6ImIxN2Q0NTEyMTE1MDkyOGYyMTQ2YWY0OWUxOTVlZmYxZWVmNWQ2NzMyNWJlMjczYTczM2ZiNzRhY2FkYWEzNDIiLCJwYXRjaERpZ2VzdCI6ImE0ODk1ZWI0NGFmYzMzNmZlY2JiYTZlNTIwY2Q2N2UxNzhkYWNlMDI3NjY1NWQxMDJmY2VmZmE4ZTVmNzA1NzAiLCJzdGF0ZUZvcm1hdElkIjoiZmx5c2Vzcy0xIn0sImNvbXBvc2l0aW9uRGlnZXN0IjoiNzMwZDcyNWM4YTU5ZDNhNzMwM2RlZjJiZWQwNDFhNTc3ZWRiNDI1NWFhYmQ0ODg5Y2UxMjkxODMxMWQ5NTJmMCIsImNvb3JkaW5hdG9yIjp7ImFkbWlzc2lvblN0YXRlIjpudWxsLCJldmVudFdhdGVybWFya3MiOnsibGFzdEV2ZW50SWQiOiJldnQtMSIsImxhc3RPcmRpbmFsIjoiNyJ9LCJleGVjdXRvclN0YXRlIjpbeyJhZ2VudElkIjoiZmx5LWEiLCJwYXlsb2FkIjoiZXhlY3V0b3ItZmx5LWEifV0sInByaW9ySW5zcGVjdGlvbiI6InByaW9yLWluc3BlY3Rpb24iLCJ0YXNrTGVkZ2VyIjoidGFzay1sZWRnZXIifSwiZW52ZWxvcGVWZXJzaW9uIjoxLCJlcGlzb2RlSWQiOiJlcGlzb2RlLTEiLCJoZWxwZXJTdGF0ZSI6W10sInBheWxvYWRzIjpbeyJieXRlTGVuZ3RoIjoiMTciLCJkaWdlc3QiOiIxMzIxZGZmYjBjZGM2ZjkwOTJjYmY3ZmEyYTVmYzY4YmJlZDEyYzk5M2Q1YWQzOTgyNjQwMTI4MTBjZTliZjkzIiwibmFtZSI6ImFnZW50LWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTQiLCJkaWdlc3QiOiIzYWVlNjBkZjdlMjllZmViYTdmNWY5OWZjNTg2NzY0N2IzNmFlYmZmMWQ1ZDNjODM4ZGJmZjMyMzEyMmU2NDYyIiwibmFtZSI6ImV4ZWN1dG9yLWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTEiLCJkaWdlc3QiOiI0MGIwMGVkMmJiYmE5MDFkNjgyMDVmZjcxYjA0YTQ0YjllZTUzYzUxY2IzMTA5YWEyY2VhYTQ0ZjFjNDU3MjdlIiwibmFtZSI6InRhc2stbGVkZ2VyIn0seyJieXRlTGVuZ3RoIjoiMTAiLCJkaWdlc3QiOiIyYzEzYjdiNGQ5YTk5MTY4MDFhYjkxOTFjMzE0ZjMxYjA0NWU5YjljNWI2NjlhNmMwNDc0ZjAyMTdlZjc1YmY1IiwibmFtZSI6InByaW9yLWluc3BlY3Rpb24ifSx7ImJ5dGVMZW5ndGgiOiI2NCIsImRpZ2VzdCI6ImY1YTVmZDQyZDE2YTIwMzAyNzk4ZWY2ZWQzMDk5NzliNDMwMDNkMjMyMGQ5ZjBlOGVhOTgzMWE5Mjc1OWZiNGIiLCJuYW1lIjoid29ybGQifV0sInBvcnRNYXAiOlt7ImFnZW50SWQiOiJmbHktYSIsInBvcnRJZCI6InBvcnQtMSJ9XSwic2NoZWR1bGVySWQiOiJsb2Nrc3RlcC12MSIsInNvdXJjZVNjb3BlIjp7ImVwb2NoIjoiZXBvY2gtMSIsInNlc3Npb25JZCI6ImRlbW8iLCJzdGVwIjoiNDIifSwid29ybGRUaW1lIjp7ImRlbm9taW5hdG9yIjoiMSIsIm51bWVyYXRvciI6IjcwMDAwMDAwMCJ9fWFnZW50LWZseS1hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQCgAAAAAAABEAAAAAAAAAEyHf+wzcb5CSy/f6Kl/Gi77RLJk9WtOYJkASgQzpv5NleGVjdXRvci1mbHktYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaAoAAAAAAAAOAAAAAAAAADruYN9+Ke/rp/X5n8WGdkezauv/HV08g42/8yMSLmRidGFzay1sZWRnZXIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgKAAAAAAAACwAAAAAAAABAsA7Su7qQHWggX/cbBKRLnuU8UcsxCaos6qRPHEVyfnByaW9yLWluc3BlY3Rpb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACICgAAAAAAAAoAAAAAAAAALBO3tNmpkWgBq5GRwxTzGwRem5xbZppsBHTwIX73W/V3b3JsZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAoAAAAAAABAAAAAAAAAAPWl/ULRaiAwJ5jvbtMJl5tDAD0jINnw6OqYMaknWftLYWdlbnQgc3RhdGUgYnl0ZXMAAAAAAAAAZXhlY3V0b3Igc3RhdGUAAHsicmFuayI6MTB9AAAAAAB7Im1hcCI6NDB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgLAAAAAAAAq++fEx+FvDZho/eB4imbENN4HZrGNC2OCAsI7/gp9r5GTFlTRVNTRg==",
|
||||||
|
"byteLength": 2824,
|
||||||
|
"layout": {
|
||||||
|
"headerBytes": 32,
|
||||||
|
"manifestOffset": "32",
|
||||||
|
"manifestBytes": 2048,
|
||||||
|
"tableOffset": "2080",
|
||||||
|
"tableEntryBytes": 112,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "agent-fly-a",
|
||||||
|
"offset": "2640",
|
||||||
|
"byteLength": "17",
|
||||||
|
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor-fly-a",
|
||||||
|
"offset": "2664",
|
||||||
|
"byteLength": "14",
|
||||||
|
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "task-ledger",
|
||||||
|
"offset": "2680",
|
||||||
|
"byteLength": "11",
|
||||||
|
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prior-inspection",
|
||||||
|
"offset": "2696",
|
||||||
|
"byteLength": "10",
|
||||||
|
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world",
|
||||||
|
"offset": "2712",
|
||||||
|
"byteLength": "64",
|
||||||
|
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"footerOffset": "2776",
|
||||||
|
"footerBytes": 48,
|
||||||
|
"totalBytes": "2824"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"corruption": [
|
||||||
|
{
|
||||||
|
"name": "a flipped magic byte",
|
||||||
|
"offset": 0,
|
||||||
|
"reason": "wrong magic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an unsupported version",
|
||||||
|
"offset": 8,
|
||||||
|
"reason": "unsupported version"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a flipped manifest byte",
|
||||||
|
"offset": 32,
|
||||||
|
"reason": "the footer digest covers the manifest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a flipped payload byte",
|
||||||
|
"offset": 2640,
|
||||||
|
"reason": "every payload carries its own digest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a flipped footer digest byte",
|
||||||
|
"offset": 2784,
|
||||||
|
"reason": "the footer digest must match the contents"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a flipped footer magic byte",
|
||||||
|
"offset": 2816,
|
||||||
|
"reason": "a truncated file cannot look complete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
|
||||||
|
"contractDigest": "7932aef30c4d2d16e428081affc4e0ad187987f5b361138d553e54fd7f843b50",
|
||||||
|
"schemaSetVersion": 1,
|
||||||
|
"schemaSetBytes": 26685,
|
||||||
|
"types": 53,
|
||||||
|
"enums": 11,
|
||||||
|
"limits": 25
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,74 @@
|
||||||
|
{
|
||||||
|
"description": "Boundary cases both languages build from a recipe, because the payload is too large to store.",
|
||||||
|
"padSchema": {
|
||||||
|
"id": "pad.v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "018689202f154300eeda48ccee8cc021c36672df03f7404097613f5898fdfdcc"
|
||||||
|
},
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "typed value exactly at the 32 KiB cap",
|
||||||
|
"kind": "padded-typed-value",
|
||||||
|
"padCharacters": 32635,
|
||||||
|
"expect": "accept",
|
||||||
|
"reason": "32768 bytes of canonical JSON is the limit, not one byte less"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "typed value one byte over the cap",
|
||||||
|
"kind": "padded-typed-value",
|
||||||
|
"padCharacters": 32636,
|
||||||
|
"expect": "reject",
|
||||||
|
"reason": "a TypedValue is at most 32 KiB of canonical JSON"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "request that fills the 64 KiB envelope exactly",
|
||||||
|
"kind": "padded-request",
|
||||||
|
"padCharacters": 60000,
|
||||||
|
"expect": "accept",
|
||||||
|
"reason": "65536 bytes including the envelope wrapper is admissible",
|
||||||
|
"envelopeTotal": 65536
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "request one byte past the envelope ceiling",
|
||||||
|
"kind": "padded-request",
|
||||||
|
"padCharacters": 60000,
|
||||||
|
"expect": "reject",
|
||||||
|
"reason": "the complete envelope must fit Flybus's 64-KiB maximum",
|
||||||
|
"envelopeTotal": 65537
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error message of 512 code points",
|
||||||
|
"kind": "error-message",
|
||||||
|
"codePoints": 512,
|
||||||
|
"expect": "accept",
|
||||||
|
"reason": "messages are <= 512 code points"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error message of 513 code points",
|
||||||
|
"kind": "error-message",
|
||||||
|
"codePoints": 513,
|
||||||
|
"expect": "reject",
|
||||||
|
"reason": "messages are <= 512 code points"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error message of 512 astral code points",
|
||||||
|
"kind": "error-message-astral",
|
||||||
|
"codePoints": 512,
|
||||||
|
"expect": "accept",
|
||||||
|
"reason": "the bound counts code points, not UTF-16 units or bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error message of 513 astral code points",
|
||||||
|
"kind": "error-message-astral",
|
||||||
|
"codePoints": 513,
|
||||||
|
"expect": "reject",
|
||||||
|
"reason": "the bound counts code points, not UTF-16 units or bytes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recipes": {
|
||||||
|
"padded-typed-value": "a TypedValue whose schema is padSchema and whose value is {\"pad\": <padCharacters> 'a' characters}; validate it",
|
||||||
|
"padded-request": "a SessionRpcRequest req-1 with scope null and params {\"pad\": <padCharacters> 'a' characters}; canonicalize it, then require the envelope to fit with an overhead of envelopeTotal minus that canonical length",
|
||||||
|
"error-message": "a SessionRpcFailure with code INTERNAL, mutation unknown and a message of <codePoints> 'x' characters",
|
||||||
|
"error-message-astral": "the same failure with a message of <codePoints> repetitions of U+10400, one code point and two UTF-16 units each"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
{
|
||||||
|
"description": "Bus callId, domain requestId, artifact identity and delivery/hold owner tokens are four types, not four spellings of one string.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"text": "call-0",
|
||||||
|
"busCallId": true,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "call-102",
|
||||||
|
"busCallId": true,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "req-41",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": true,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "dlv-7",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": "delivery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "own-9",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": "hold"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "req-041",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "call-",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "call",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "callid-1",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "request-1",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "REQ-1",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "dlv-07",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "sub-1",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "svc-1",
|
||||||
|
"busCallId": false,
|
||||||
|
"domainRequestId": false,
|
||||||
|
"ownerToken": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifact": {
|
||||||
|
"ref": {
|
||||||
|
"storeId": "store-1",
|
||||||
|
"artifactId": "frame-1",
|
||||||
|
"generation": "1",
|
||||||
|
"byteLength": "92160",
|
||||||
|
"contentType": "image/x-rgba8",
|
||||||
|
"digest": null
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"storeId": "store-1",
|
||||||
|
"artifactId": "frame-1",
|
||||||
|
"generation": "1"
|
||||||
|
},
|
||||||
|
"reason": "the identity is the naming half of an ArtifactRef; byteLength, contentType and digest are not identity, and an AssetRef is not an artifact at all"
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"id": "profile-fly-a",
|
||||||
|
"digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"byteLength": "4096",
|
||||||
|
"format": "flyprofile"
|
||||||
|
}
|
||||||
|
}
|
||||||
3900
services/flysim/crates/fly-session-types/fixtures/invalid.json
Normal file
3900
services/flysim/crates/fly-session-types/fixtures/invalid.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,667 @@
|
||||||
|
{
|
||||||
|
"description": "ipc-v1 section 5: the operation key of a step mutation and the canonical body a duplicate is compared against.",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"name": "Agent.Prepare on fly-a at step 41",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"digest": "eda73d62bb5d616052997ab5e9d2c4ba5872fdefb44cf706910a18a24d39225f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agent.Prepare on fly-b at step 41",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"workerId": "fly-b",
|
||||||
|
"digest": "5a7fd15da236f3fd6343698a8cc5c4f14fa7d84683a819d5a28904d90f700c4e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agent.Commit on fly-a at step 41",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"method": "Agent.Commit",
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"digest": "2a89a120e5594895d024b4ffc2a7d225bd27322cfd345ebd78e80ed7b271c680"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agent.Prepare on fly-a at step 42",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "42"
|
||||||
|
},
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"digest": "5bb184cab6c3c2d7567a7979d860a7e9b26ea52bdc92b139053b0a2f81a9ed4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agent.Prepare on fly-a in another epoch",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-2",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"digest": "fa1d9cf3936aaffe3cedd0e586d7763efca2d250e89dd85b849cf35fbd91a477"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Environment.Advance at step 41",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"workerId": "world",
|
||||||
|
"digest": "c89c390aa78ef2895d9e2b86a6000160923dc54e3be75529e0289cd18b9088b1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bodies": [
|
||||||
|
{
|
||||||
|
"name": "Agent.Prepare body",
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"digest": "74c9d5f27b2cb06922cc3ca13d87c67faea0a6c73aefbbdb21ba8f52c6d33f60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Environment.Advance body",
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"batchId": "batch-41",
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"down": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "up",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "down",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"down": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"axes": [
|
||||||
|
{
|
||||||
|
"id": "stick-x",
|
||||||
|
"value": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trigger",
|
||||||
|
"value": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"digest": "5aaae9c083336460a2ce34fb50e191f7108444a953f09b952e97a48b7962ee79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Worker.Hello body with no scope",
|
||||||
|
"method": "Worker.Hello",
|
||||||
|
"scope": null,
|
||||||
|
"params": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"expectedWorkerId": "fly-a",
|
||||||
|
"role": "agent",
|
||||||
|
"supportedMajors": [
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"digest": "0c07677a45178d8680dddbeec56d4e09f6ccbfe5fe77d9d4ff2fb07b369febaf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pairs": [
|
||||||
|
{
|
||||||
|
"name": "the same operation retried on a new bus call",
|
||||||
|
"left": {
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"batchId": "batch-41",
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"down": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "up",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "down",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"down": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"axes": [
|
||||||
|
{
|
||||||
|
"id": "stick-x",
|
||||||
|
"value": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trigger",
|
||||||
|
"value": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"batchId": "batch-41",
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"down": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "up",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "down",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"down": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"axes": [
|
||||||
|
{
|
||||||
|
"id": "stick-x",
|
||||||
|
"value": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trigger",
|
||||||
|
"value": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerId": "world",
|
||||||
|
"sameKey": true,
|
||||||
|
"sameBody": true,
|
||||||
|
"reason": "a retry reuses the requestId and body; only the callId changes, and the callId is not in either digest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the same batch id with altered controls",
|
||||||
|
"left": {
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"batchId": "batch-41",
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"down": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "up",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "down",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"down": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"axes": [
|
||||||
|
{
|
||||||
|
"id": "stick-x",
|
||||||
|
"value": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trigger",
|
||||||
|
"value": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"method": "Environment.Advance",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"batchId": "batch-41",
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"portId": "port-1",
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"down": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "up",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "down",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left",
|
||||||
|
"down": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "right",
|
||||||
|
"down": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"axes": [
|
||||||
|
{
|
||||||
|
"id": "stick-x",
|
||||||
|
"value": 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trigger",
|
||||||
|
"value": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerId": "world",
|
||||||
|
"sameKey": true,
|
||||||
|
"sameBody": false,
|
||||||
|
"reason": "same key, changed body: CONFLICT, never a second world mutation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "params written with their keys in another order",
|
||||||
|
"left": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"agentId": "fly-a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"sameKey": true,
|
||||||
|
"sameBody": true,
|
||||||
|
"reason": "RFC 8785 sorts keys, so serialization order is not a body change"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the same body one step later",
|
||||||
|
"left": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "42"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"sameKey": false,
|
||||||
|
"sameBody": false,
|
||||||
|
"reason": "the step is part of both the key and the body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "the same body on another worker",
|
||||||
|
"left": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||||
|
"interval": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||||
|
"preStepStimulations": [
|
||||||
|
{
|
||||||
|
"id": "stim-1",
|
||||||
|
"kindId": "sugar",
|
||||||
|
"durationMs": 50.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"rightWorkerId": "fly-b",
|
||||||
|
"sameKey": false,
|
||||||
|
"sameBody": true,
|
||||||
|
"reason": "the worker is part of the key, not of the body"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rejected": [
|
||||||
|
{
|
||||||
|
"name": "a body carrying a bus callId",
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"callId": "call-2",
|
||||||
|
"agentId": "fly-a"
|
||||||
|
},
|
||||||
|
"reason": "the canonical body excludes bus callIds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a body carrying a delivery id",
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"deliveryId": "dlv-7"
|
||||||
|
},
|
||||||
|
"reason": "the canonical body excludes deliveryIds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a body carrying an owner token",
|
||||||
|
"method": "Agent.Commit",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"nextInput": {
|
||||||
|
"frame": {
|
||||||
|
"ownerId": "own-3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reason": "the canonical body excludes owner tokens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a body carrying a pinned service incarnation",
|
||||||
|
"method": "Agent.Prepare",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"expectedIncarnation": "svc-1"
|
||||||
|
},
|
||||||
|
"reason": "route pinning is transport state, not domain state"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "an empty method",
|
||||||
|
"method": "",
|
||||||
|
"scope": {
|
||||||
|
"sessionId": "demo",
|
||||||
|
"epoch": "epoch-1",
|
||||||
|
"step": "41"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"reason": "methods are 1..=128 printable ASCII characters"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
249
services/flysim/crates/fly-session-types/fixtures/rational.json
Normal file
249
services/flysim/crates/fly-session-types/fixtures/rational.json
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
{
|
||||||
|
"description": "Checked rational arithmetic and the step-v1 section 5 tick accumulator.",
|
||||||
|
"accumulator": [
|
||||||
|
{
|
||||||
|
"name": "a synthetic 60 Hz environment on a 1 ms model tick",
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"tickDuration": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"ticks": "16",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "2000000",
|
||||||
|
"denominator": "3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticks": "17",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticks": "17",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalTicks": "50",
|
||||||
|
"reason": "16, 17, 17 and a remainder of zero after three steps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a whole millisecond cadence never accumulates a remainder",
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "16000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"tickDuration": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"ticks": "16",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticks": "16",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalTicks": "32",
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a step shorter than one tick advances nothing and keeps the remainder",
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "500000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"tickDuration": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"ticks": "0",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "500000",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticks": "1",
|
||||||
|
"remainder": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalTicks": "1",
|
||||||
|
"reason": "the fractional period is carried, not rounded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"add": [
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"sum": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "6"
|
||||||
|
},
|
||||||
|
"sum": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "18446744073709551615",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "2"
|
||||||
|
},
|
||||||
|
"error": "reduced value does not fit U64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtract": [
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"difference": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "2"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"difference": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "2"
|
||||||
|
},
|
||||||
|
"error": "subtraction would be negative"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multiply": [
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"k": "17",
|
||||||
|
"product": {
|
||||||
|
"numerator": "17000000",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"k": "1000",
|
||||||
|
"product": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "18446744073709551615",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"k": "2",
|
||||||
|
"error": "reduced value does not fit U64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compare": [
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "0",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"ordering": "less"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"ordering": "equal",
|
||||||
|
"reason": "equal values compare equal; an unreduced 2/6 never reaches a comparison, because it never parses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"numerator": "1000000",
|
||||||
|
"denominator": "1"
|
||||||
|
},
|
||||||
|
"ordering": "greater"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
77
services/flysim/crates/fly-session-types/fixtures/raw.json
Normal file
77
services/flysim/crates/fly-session-types/fixtures/raw.json
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"description": "Byte sequences every implementation must refuse before validation.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "duplicate key at the top level",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEiLCJzdGVwIjoiMiJ9",
|
||||||
|
"reason": "duplicate JSON keys are refused at any depth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duplicate key inside a nested object",
|
||||||
|
"type": "TypedValue",
|
||||||
|
"base64": "eyJzY2hlbWEiOnsiaWQiOiJhLnYxIiwidmVyc2lvbiI6MSwiZGlnZXN0IjoiYmJhNzk1MWMwNDY2NGNkNDliYjZlZWQxZGE2ZGMxYTRlMTdlOTg5ZTc4N2JmMmU3MmY1OTMzYTVjNjE3MTM3MiJ9LCJ2YWx1ZSI6eyJ4IjoxLCJ4IjoyfX0=",
|
||||||
|
"reason": "duplicate JSON keys are refused at any depth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid UTF-8 in a string",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "eyJzZXNzaW9uSWQiOiJkZf9tbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
|
||||||
|
"reason": "the envelope is UTF-8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid UTF-8 in a key",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "eyJzZXNzaW9u/0lkIjoiZGVtbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
|
||||||
|
"reason": "the envelope is UTF-8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NaN literal",
|
||||||
|
"type": "Stimulus",
|
||||||
|
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6TmFOfQ==",
|
||||||
|
"reason": "NaN and Infinity are not JSON"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Infinity literal",
|
||||||
|
"type": "Stimulus",
|
||||||
|
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6SW5maW5pdHl9",
|
||||||
|
"reason": "NaN and Infinity are not JSON"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number that overflows a double",
|
||||||
|
"type": "Stimulus",
|
||||||
|
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6MWU5OTl9",
|
||||||
|
"reason": "a non-finite number never survives parsing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "integer past the exact double range",
|
||||||
|
"type": "Stimulus",
|
||||||
|
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6OTAwNzE5OTI1NDc0MDk5M30=",
|
||||||
|
"reason": "canonical JSON cannot encode it exactly; counters are U64 strings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trailing data after the object",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEifSB7fQ==",
|
||||||
|
"reason": "one value per payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "truncated object",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIg==",
|
||||||
|
"reason": "partial JSON is refused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty payload",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "",
|
||||||
|
"reason": "an empty payload is not a JSON object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a bare array",
|
||||||
|
"type": "Scope",
|
||||||
|
"base64": "W10=",
|
||||||
|
"reason": "a payload is an object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,185 @@
|
||||||
|
{
|
||||||
|
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
|
||||||
|
"algorithm": "seed-derivation-v1",
|
||||||
|
"prefix": "flybrain/seed-derivation-v1",
|
||||||
|
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
|
||||||
|
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
|
||||||
|
"vectors": [
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n0\nfly-a\n",
|
||||||
|
"materialDigest": "6cf7c34a422f4cdd890c64d0ef5a072e4f6ddc2ee18e83ee24a5f2214f4960d1",
|
||||||
|
"seed": 1828176714
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "fly-b",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n0\nfly-b\n",
|
||||||
|
"materialDigest": "48a52f4069cfca001ba5ee6ae870dccb5a5bd59d61babf333a01749b6a8f4025",
|
||||||
|
"seed": 1218785088
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "fly-c",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n0\nfly-c\n",
|
||||||
|
"materialDigest": "53a83c2d0cfce5a3356aab3b0b73a270201a2dff7816168a4167c71a32d863fe",
|
||||||
|
"seed": 1403534381
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "fly-d",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n0\nfly-d\n",
|
||||||
|
"materialDigest": "f202513f32fe6070eee65ad72027490f9218b86dc8cf74e4e3563f02d532ea94",
|
||||||
|
"seed": -234729153
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "1",
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n1\nfly-a\n",
|
||||||
|
"materialDigest": "6eab9d6d6d002ffc0e2cdf2d64dd8226f90c391e45641512d14edbb244225698",
|
||||||
|
"seed": 1856740717
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "1",
|
||||||
|
"agentId": "fly-b",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n1\nfly-b\n",
|
||||||
|
"materialDigest": "00115cb341d839490c5485462fd7eedd8b6baeda3748054fcba0d1558e471a6c",
|
||||||
|
"seed": 1137843
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "1",
|
||||||
|
"agentId": "fly-c",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n1\nfly-c\n",
|
||||||
|
"materialDigest": "af97482074d6d36e5f5920aaa1d87f02365045f3712300dbd243584f2cb4a64c",
|
||||||
|
"seed": -1349040096
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "1",
|
||||||
|
"agentId": "fly-d",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n1\nfly-d\n",
|
||||||
|
"materialDigest": "1081b24880040dcc4d442f88451513e4fbcf8dae9ccb79329dd289fabbc7938b",
|
||||||
|
"seed": 276935240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n42\nfly-a\n",
|
||||||
|
"materialDigest": "f4f9f271d53e94c38c6260b99840513f9eb70e5bb442d63345e217185e6ba9f6",
|
||||||
|
"seed": -184946063
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentId": "fly-b",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n42\nfly-b\n",
|
||||||
|
"materialDigest": "1feb285e834138ec823ffea696fbe5fb3c211f349ce17140e25286d06c56ece8",
|
||||||
|
"seed": 535504990
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentId": "fly-c",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n42\nfly-c\n",
|
||||||
|
"materialDigest": "b5a251ed4521f91eab1d8a6813880794c5c6706eafc137d8ebc169f743c60eed",
|
||||||
|
"seed": -1247653395
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentId": "fly-d",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n42\nfly-d\n",
|
||||||
|
"materialDigest": "e36d6e40f27a4ffe473351d4bd01154da2efb3c3038d88f3aa48f77963d465c6",
|
||||||
|
"seed": -479367616
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "9223372036854775808",
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-a\n",
|
||||||
|
"materialDigest": "d59f513b6fdce1646ed13907302e0cb3ca4696738dd7df38e9581397508ec933",
|
||||||
|
"seed": -710979269
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "9223372036854775808",
|
||||||
|
"agentId": "fly-b",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-b\n",
|
||||||
|
"materialDigest": "c8de8ca0279829c438fa19c07f05768cfe050e8c21bdaa0fe3c68f8048b4e1a3",
|
||||||
|
"seed": -924939104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "9223372036854775808",
|
||||||
|
"agentId": "fly-c",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-c\n",
|
||||||
|
"materialDigest": "354ad1ec388aa7be6136151d0f6281cc17279b62e95d458c96f392aaac40ee62",
|
||||||
|
"seed": 894095852
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "9223372036854775808",
|
||||||
|
"agentId": "fly-d",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-d\n",
|
||||||
|
"materialDigest": "58e61deb4777b7702bdcd7edf5a5bcd0d51273699cc1051c7fbc9a5ffedd9e15",
|
||||||
|
"seed": 1491475947
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "18446744073709551615",
|
||||||
|
"agentId": "fly-a",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-a\n",
|
||||||
|
"materialDigest": "f888eb95bcab276936fce5f72df3a0741e36da26722f43cc2a974932dfd42cd1",
|
||||||
|
"seed": -125244523
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "18446744073709551615",
|
||||||
|
"agentId": "fly-b",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-b\n",
|
||||||
|
"materialDigest": "d343df049e8ef71617e9af7a321cf498d61083c50229f2d6852077670c86acbd",
|
||||||
|
"seed": -750526716
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "18446744073709551615",
|
||||||
|
"agentId": "fly-c",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-c\n",
|
||||||
|
"materialDigest": "88016b220245b431a4eb510d31b643475bb2496130b65ae81b165acd5a899292",
|
||||||
|
"seed": -2013172958
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "18446744073709551615",
|
||||||
|
"agentId": "fly-d",
|
||||||
|
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-d\n",
|
||||||
|
"materialDigest": "d9b5bee718c87599c9ef0b3c16f361dadf714a33f7dd1209a21108f4df700b16",
|
||||||
|
"seed": -642400537
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"composition": {
|
||||||
|
"masterSeed": "42",
|
||||||
|
"agentIds": [
|
||||||
|
"fly-a",
|
||||||
|
"fly-b",
|
||||||
|
"fly-c",
|
||||||
|
"fly-d"
|
||||||
|
],
|
||||||
|
"seeds": [
|
||||||
|
-184946063,
|
||||||
|
535504990,
|
||||||
|
-1247653395,
|
||||||
|
-479367616
|
||||||
|
],
|
||||||
|
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids"
|
||||||
|
},
|
||||||
|
"invalid": [
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "Fly-A",
|
||||||
|
"reason": "an agent id is an Id: lowercase"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentId": "",
|
||||||
|
"reason": "an agent id is 1..=64 characters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"masterSeed": "0",
|
||||||
|
"agentIds": [
|
||||||
|
"fly-a",
|
||||||
|
"fly-a"
|
||||||
|
],
|
||||||
|
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1141
services/flysim/crates/fly-session-types/fixtures/traces.json
Normal file
1141
services/flysim/crates/fly-session-types/fixtures/traces.json
Normal file
File diff suppressed because it is too large
Load diff
2787
services/flysim/crates/fly-session-types/fixtures/valid.json
Normal file
2787
services/flysim/crates/fly-session-types/fixtures/valid.json
Normal file
File diff suppressed because it is too large
Load diff
273
services/flysim/crates/fly-session-types/src/canonical.rs
Normal file
273
services/flysim/crates/fly-session-types/src/canonical.rs
Normal file
|
|
@ -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<f64> {
|
||||||
|
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<u16> {
|
||||||
|
key.encode_utf16().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical JSON text of `value`.
|
||||||
|
pub fn canonicalize(value: &Value) -> Result<String> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String> {
|
||||||
|
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<Value> {
|
||||||
|
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<usize> {
|
||||||
|
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<OperationKey> {
|
||||||
|
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<String> {
|
||||||
|
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<Value> {
|
||||||
|
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<String> {
|
||||||
|
digest_of(&canonical_body(method, scope, params)?)
|
||||||
|
}
|
||||||
368
services/flysim/crates/fly-session-types/src/checkpoint.rs
Normal file
368
services/flysim/crates/fly-session-types/src/checkpoint.rs
Normal file
|
|
@ -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<PayloadEntry>,
|
||||||
|
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<u8>)]) -> Result<Layout> {
|
||||||
|
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<u8>)]) -> Result<Vec<u8>> {
|
||||||
|
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<u8>)>,
|
||||||
|
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<Envelope> {
|
||||||
|
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
|
||||||
|
}
|
||||||
108
services/flysim/crates/fly-session-types/src/fixtures.rs
Normal file
108
services/flysim/crates/fly-session-types/src/fixtures.rs
Normal file
|
|
@ -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<Value> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Value>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
56
services/flysim/crates/fly-session-types/src/lib.rs
Normal file
56
services/flysim/crates/fly-session-types/src/lib.rs
Normal file
|
|
@ -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;
|
||||||
716
services/flysim/crates/fly-session-types/src/media.rs
Normal file
716
services/flysim/crates/fly-session-types/src/media.rs
Normal file
|
|
@ -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<ViewDescriptor> {
|
||||||
|
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<u64>,
|
||||||
|
) -> 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<ViewRef> {
|
||||||
|
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<AudioDescriptor> {
|
||||||
|
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<AudioRef> {
|
||||||
|
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<Vec<ViewRef>> {
|
||||||
|
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<Vec<AudioRef>> {
|
||||||
|
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<ArtifactRef> {
|
||||||
|
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<CaptureParams> {
|
||||||
|
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<CaptureResult> {
|
||||||
|
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<StageRestoreParams> {
|
||||||
|
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<StageRestoreResult> {
|
||||||
|
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<ActivateRestoreParams> {
|
||||||
|
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<crate::workers::WorldObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ActivateRestoreResult> {
|
||||||
|
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> {
|
||||||
|
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> {
|
||||||
|
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<f64> {
|
||||||
|
finite_in(f, key, 0.0, 1.0)
|
||||||
|
}
|
||||||
450
services/flysim/crates/fly-session-types/src/publishing.rs
Normal file
450
services/flysim/crates/fly-session-types/src/publishing.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub supported_stimuli: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<AgentDescriptor>,
|
||||||
|
pub assets: Vec<AssetRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainType for SessionDescriptor {
|
||||||
|
const TYPE_NAME: &'static str = "SessionDescriptor";
|
||||||
|
|
||||||
|
fn from_json(value: &Value) -> Result<SessionDescriptor> {
|
||||||
|
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<TypedValue>,
|
||||||
|
pub applied_controls: Option<PortControl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<SnapshotAgent>,
|
||||||
|
pub progress: TypedValue,
|
||||||
|
pub views: Vec<ViewRef>,
|
||||||
|
pub audio: Vec<AudioRef>,
|
||||||
|
pub event_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CommittedSnapshot> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
420
services/flysim/crates/fly-session-types/src/rpc.rs
Normal file
420
services/flysim/crates/fly-session-types/src/rpc.rs
Normal file
|
|
@ -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<ErrorCode> {
|
||||||
|
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<MutationCertainty> {
|
||||||
|
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<Scope>,
|
||||||
|
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<String> {
|
||||||
|
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<canonical::OperationKey> {
|
||||||
|
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<SessionRpcRequest> {
|
||||||
|
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<Scope>,
|
||||||
|
pub result: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainType for SessionRpcSuccess {
|
||||||
|
const TYPE_NAME: &'static str = "SessionRpcSuccess";
|
||||||
|
|
||||||
|
fn from_json(value: &Value) -> Result<SessionRpcSuccess> {
|
||||||
|
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<Scope>,
|
||||||
|
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<SessionRpcFailure> {
|
||||||
|
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<SessionRpcOutcome> {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
725
services/flysim/crates/fly-session-types/src/scalar.rs
Normal file
725
services/flysim/crates/fly-session-types/src/scalar.rs
Normal file
|
|
@ -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<T> = std::result::Result<T, WireError>;
|
||||||
|
|
||||||
|
/// 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<Self>;
|
||||||
|
|
||||||
|
/// 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<T>(message: impl Into<String>) -> Result<T> {
|
||||||
|
Err(WireError(message.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wire_err(message: impl Into<String>) -> 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<u64> {
|
||||||
|
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<f64> {
|
||||||
|
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<f64> {
|
||||||
|
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<i32> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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<T>(
|
||||||
|
f: &mut Fields<'_>,
|
||||||
|
key: &'static str,
|
||||||
|
lo: usize,
|
||||||
|
hi: usize,
|
||||||
|
read: impl Fn(&Value) -> Result<T>,
|
||||||
|
) -> Result<Vec<T>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<Item = &'a str>, 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<Item = &'a str>,
|
||||||
|
expected: impl IntoIterator<Item = &'a str>,
|
||||||
|
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<Scope> {
|
||||||
|
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<Option<Scope>> {
|
||||||
|
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<Scope> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<Ordering> {
|
||||||
|
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<RationalNs> {
|
||||||
|
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<SchemaRef> {
|
||||||
|
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<SchemaRef> {
|
||||||
|
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<TypedValue> {
|
||||||
|
let t = TypedValue { schema, value };
|
||||||
|
t.validate()?;
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nullable_from_json(value: &Value) -> Result<Option<TypedValue>> {
|
||||||
|
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<usize> {
|
||||||
|
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<TypedValue> {
|
||||||
|
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-<U64>` (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-<U64>`.
|
||||||
|
Delivery,
|
||||||
|
/// An explicit artifact hold, `own-<U64>`.
|
||||||
|
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-<U64>` 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<Self> {
|
||||||
|
match wire::parse_serial_id($prefix, s) {
|
||||||
|
Some(_) => Ok(Self(s.to_owned())),
|
||||||
|
None => err(concat!($what, " must be canonical ", $prefix, "-<U64>")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self> {
|
||||||
|
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<Option<Self>> {
|
||||||
|
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<OwnerToken> {
|
||||||
|
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-<U64> or own-<U64>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
1109
services/flysim/crates/fly-session-types/src/schema.rs
Normal file
1109
services/flysim/crates/fly-session-types/src/schema.rs
Normal file
File diff suppressed because it is too large
Load diff
70
services/flysim/crates/fly-session-types/src/seed.rs
Normal file
70
services/flysim/crates/fly-session-types/src/seed.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<i32> {
|
||||||
|
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<Vec<i32>> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
486
services/flysim/crates/fly-session-types/src/trace.rs
Normal file
486
services/flysim/crates/fly-session-types/src/trace.rs
Normal file
|
|
@ -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<TraceAgent>,
|
||||||
|
pub batch_id: String,
|
||||||
|
pub control_digest: String,
|
||||||
|
pub acknowledged_boundary: u64,
|
||||||
|
/// Sorted by view id.
|
||||||
|
pub observation_boundaries: Vec<TraceObservation>,
|
||||||
|
/// Task outcome ids in task order.
|
||||||
|
pub outcome_ids: Vec<String>,
|
||||||
|
/// Task event ids in task order.
|
||||||
|
pub event_ids: Vec<String>,
|
||||||
|
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<String> {
|
||||||
|
canonical::digest_of(&self.normalized().to_json())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainType for TraceBehaviour {
|
||||||
|
const TYPE_NAME: &'static str = "TraceBehaviour";
|
||||||
|
|
||||||
|
fn from_json(value: &Value) -> Result<TraceBehaviour> {
|
||||||
|
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<TraceRequest>,
|
||||||
|
pub advance_request_id: DomainRequestId,
|
||||||
|
pub commit_request_ids: Vec<TraceRequest>,
|
||||||
|
/// The transport correlation ids this transition happened to use. A safe retry changes
|
||||||
|
/// these and nothing in [`TraceBehaviour`].
|
||||||
|
pub bus_call_ids: Vec<BusCallId>,
|
||||||
|
pub delivery_ids: Vec<OwnerToken>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainType for TraceOperational {
|
||||||
|
const TYPE_NAME: &'static str = "TraceOperational";
|
||||||
|
|
||||||
|
fn from_json(value: &Value) -> Result<TraceOperational> {
|
||||||
|
let mut f = Fields::new(value, "TraceOperational")?;
|
||||||
|
let wall_time_ns = f.u64_string("wallTimeNs")?;
|
||||||
|
let read_requests = |v: &Value| -> Result<TraceRequest> {
|
||||||
|
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<String> {
|
||||||
|
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<TransitionTrace> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
2405
services/flysim/crates/fly-session-types/src/workers.rs
Normal file
2405
services/flysim/crates/fly-session-types/src/workers.rs
Normal file
File diff suppressed because it is too large
Load diff
192
services/flysim/crates/fly-session-types/tests/canonical_json.rs
Normal file
192
services/flysim/crates/fly-session-types/tests/canonical_json.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5.
|
||||||
|
|
||||||
|
use fly_session_types::scalar::{DomainType, Scope};
|
||||||
|
use fly_session_types::{canonical, fixtures};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_keys_are_sorted_by_utf16_code_unit() {
|
||||||
|
let value = json!({"b": 1, "a": 2, "A": 3, "\u{00e9}": 4, "\u{10400}": 5, "\u{ff21}": 6});
|
||||||
|
assert_eq!(
|
||||||
|
canonical::canonicalize(&value).expect("canonicalizable"),
|
||||||
|
"{\"A\":3,\"a\":2,\"b\":1,\"\u{00e9}\":4,\"\u{10400}\":5,\"\u{ff21}\":6}",
|
||||||
|
"keys sort by UTF-16 code unit, so an astral key (leading surrogate D801) sorts \
|
||||||
|
before U+FF21, which is where a JavaScript string sort puts it too and where a sort \
|
||||||
|
by Unicode code point would not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbers_print_the_way_ecmascript_prints_them() {
|
||||||
|
let file = fixtures::load("boundaries.json").expect("boundaries.json");
|
||||||
|
for case in file.get("doubles").and_then(Value::as_array).expect("doubles") {
|
||||||
|
let value = case.get("value").expect("value");
|
||||||
|
let accept = case.get("accept").and_then(Value::as_bool).expect("accept");
|
||||||
|
let outcome = canonical::canonicalize(value);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.is_ok(),
|
||||||
|
accept,
|
||||||
|
"{value}: {}",
|
||||||
|
fixtures::field(case, "reason").unwrap_or("")
|
||||||
|
);
|
||||||
|
if let (Ok(text), Some(expected)) = (outcome, case.get("canonical").and_then(Value::as_str))
|
||||||
|
{
|
||||||
|
assert_eq!(text, expected, "{value} must print as {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strings_are_escaped_the_way_json_stringify_escapes_them() {
|
||||||
|
let value = json!({"s": "quote \" backslash \\ tab \t newline \n bell \u{7} del \u{7f} e\u{301}"});
|
||||||
|
assert_eq!(
|
||||||
|
canonical::canonicalize(&value).expect("canonicalizable"),
|
||||||
|
"{\"s\":\"quote \\\" backslash \\\\ tab \\t newline \\n bell \\u0007 del \u{7f} e\u{301}\"}",
|
||||||
|
"only the escapes JSON.stringify emits, with lowercase hex"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_form_does_not_depend_on_the_input_formatting() {
|
||||||
|
let compact = br#"{"b":[1,2,{"y":true,"x":null}],"a":"z"}"#;
|
||||||
|
let pretty = br#"{
|
||||||
|
"a" : "z",
|
||||||
|
"b": [ 1, 2, { "x": null, "y": true } ]
|
||||||
|
}"#;
|
||||||
|
let left = canonical::parse_strict(compact).expect("parses");
|
||||||
|
let right = canonical::parse_strict(pretty).expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
canonical::canonicalize(&left).expect("canonicalizable"),
|
||||||
|
canonical::canonicalize(&right).expect("canonicalizable")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical::digest_of(&left).expect("digest"),
|
||||||
|
canonical::digest_of(&right).expect("digest"),
|
||||||
|
"whitespace and key order are not content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_keys_and_invalid_utf8_never_parse() {
|
||||||
|
assert!(canonical::parse_strict(br#"{"a":1,"a":2}"#).is_err());
|
||||||
|
assert!(canonical::parse_strict(b"{\"a\":\"\xff\"}").is_err());
|
||||||
|
assert!(canonical::parse_strict(br#"{"a":1} {"b":2}"#).is_err());
|
||||||
|
assert!(canonical::parse_strict(br#"{"a":NaN}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_envelope_over_64_kib_is_refused() {
|
||||||
|
let big = json!({"pad": "a".repeat(canonical::MAX_ENVELOPE_BYTES)});
|
||||||
|
assert!(canonical::require_envelope_fit(&big, 0).is_err());
|
||||||
|
let small = json!({"pad": "a"});
|
||||||
|
let length = canonical::canonicalize(&small).expect("canonicalizable").len();
|
||||||
|
assert_eq!(
|
||||||
|
canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length)
|
||||||
|
.expect("fits exactly"),
|
||||||
|
canonical::MAX_ENVELOPE_BYTES
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length + 1)
|
||||||
|
.is_err(),
|
||||||
|
"one byte past the ceiling is refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_keys_match_the_fixture_and_separate_the_operations_they_should() {
|
||||||
|
let file = fixtures::load("operations.json").expect("operations.json");
|
||||||
|
let mut digests: Vec<(String, String)> = Vec::new();
|
||||||
|
for case in file.get("keys").and_then(Value::as_array).expect("keys") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope");
|
||||||
|
let method = fixtures::field(case, "method").expect("method");
|
||||||
|
let worker = fixtures::field(case, "workerId").expect("workerId");
|
||||||
|
let key = canonical::OperationKey::new(scope, method, worker).expect("a key");
|
||||||
|
let digest = key.digest().expect("digest");
|
||||||
|
assert_eq!(
|
||||||
|
digest,
|
||||||
|
fixtures::field(case, "digest").expect("digest"),
|
||||||
|
"{name}: operation key digest must match the fixture"
|
||||||
|
);
|
||||||
|
digests.push((name.to_owned(), digest));
|
||||||
|
}
|
||||||
|
for (index, (name, digest)) in digests.iter().enumerate() {
|
||||||
|
for (other_name, other) in &digests[index + 1..] {
|
||||||
|
assert_ne!(
|
||||||
|
digest, other,
|
||||||
|
"{name} and {other_name} are different operations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_bodies_match_the_fixture() {
|
||||||
|
let file = fixtures::load("operations.json").expect("operations.json");
|
||||||
|
for case in file.get("bodies").and_then(Value::as_array).expect("bodies") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let method = fixtures::field(case, "method").expect("method");
|
||||||
|
let scope = match case.get("scope") {
|
||||||
|
Some(Value::Null) | None => None,
|
||||||
|
Some(v) => Some(Scope::from_json(v).expect("scope")),
|
||||||
|
};
|
||||||
|
let params = case.get("params").expect("params");
|
||||||
|
assert_eq!(
|
||||||
|
canonical::body_digest(method, scope.as_ref(), params).expect("digest"),
|
||||||
|
fixtures::field(case, "digest").expect("digest"),
|
||||||
|
"{name}: canonical body digest must match the fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pairs that decide whether a duplicate is a safe replay or a CONFLICT.
|
||||||
|
#[test]
|
||||||
|
fn operation_pairs_agree_with_the_fixture_about_sameness() {
|
||||||
|
let file = fixtures::load("operations.json").expect("operations.json");
|
||||||
|
for case in file.get("pairs").and_then(Value::as_array).expect("pairs") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let reason = fixtures::field(case, "reason").expect("reason");
|
||||||
|
let worker = fixtures::field(case, "workerId").expect("workerId");
|
||||||
|
let right_worker = fixtures::field(case, "rightWorkerId").unwrap_or(worker);
|
||||||
|
let side = |key: &str, worker: &str| {
|
||||||
|
let value = case.get(key).expect("side");
|
||||||
|
let method = fixtures::field(value, "method").expect("method");
|
||||||
|
let scope = Scope::from_json(value.get("scope").expect("scope")).expect("scope");
|
||||||
|
let params = value.get("params").expect("params");
|
||||||
|
let key_digest = canonical::OperationKey::new(scope.clone(), method, worker)
|
||||||
|
.expect("a key")
|
||||||
|
.digest()
|
||||||
|
.expect("digest");
|
||||||
|
let body = canonical::body_digest(method, Some(&scope), params).expect("digest");
|
||||||
|
(key_digest, body)
|
||||||
|
};
|
||||||
|
let (left_key, left_body) = side("left", worker);
|
||||||
|
let (right_key, right_body) = side("right", right_worker);
|
||||||
|
assert_eq!(
|
||||||
|
left_key == right_key,
|
||||||
|
case.get("sameKey").and_then(Value::as_bool).expect("sameKey"),
|
||||||
|
"{name}: operation key sameness. {reason}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
left_body == right_body,
|
||||||
|
case.get("sameBody").and_then(Value::as_bool).expect("sameBody"),
|
||||||
|
"{name}: canonical body sameness. {reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_domain_body_can_never_carry_a_bus_identity() {
|
||||||
|
let file = fixtures::load("operations.json").expect("operations.json");
|
||||||
|
for case in file.get("rejected").and_then(Value::as_array).expect("rejected") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let method = fixtures::field(case, "method").expect("method");
|
||||||
|
let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope");
|
||||||
|
let params = case.get("params").expect("params");
|
||||||
|
assert!(
|
||||||
|
canonical::body_digest(method, Some(&scope), params).is_err(),
|
||||||
|
"{name} must be refused: {}",
|
||||||
|
fixtures::field(case, "reason").unwrap_or("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! The `FLYSESS1` envelope layout: its fixture, its offsets and the corruptions it refuses.
|
||||||
|
|
||||||
|
use fly_session_types::{canonical, checkpoint, fixtures};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn envelope_bytes(file: &Value) -> Vec<u8> {
|
||||||
|
fixtures::decode_base64(
|
||||||
|
file.get("envelope")
|
||||||
|
.and_then(|e| e.get("base64"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("base64"),
|
||||||
|
)
|
||||||
|
.expect("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_envelope_decodes_to_its_recorded_layout() {
|
||||||
|
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
|
||||||
|
let bytes = envelope_bytes(&file);
|
||||||
|
let envelope = checkpoint::decode(&bytes).expect("a valid envelope");
|
||||||
|
checkpoint::validate_manifest(&envelope).expect("a complete manifest");
|
||||||
|
|
||||||
|
let layout = file["envelope"]["layout"].clone();
|
||||||
|
assert_eq!(&bytes[0..8], checkpoint::MAGIC);
|
||||||
|
assert_eq!(
|
||||||
|
bytes.len().to_string(),
|
||||||
|
layout["totalBytes"].as_str().expect("totalBytes")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envelope.layout.table_offset.to_string(),
|
||||||
|
layout["tableOffset"].as_str().expect("tableOffset")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
envelope.layout.manifest_bytes,
|
||||||
|
layout["manifestBytes"].as_u64().expect("manifestBytes") as u32
|
||||||
|
);
|
||||||
|
let entries = layout["entries"].as_array().expect("entries");
|
||||||
|
assert_eq!(envelope.layout.entries.len(), entries.len());
|
||||||
|
for (entry, recorded) in envelope.layout.entries.iter().zip(entries) {
|
||||||
|
assert_eq!(entry.name, recorded["name"].as_str().expect("name"));
|
||||||
|
assert_eq!(
|
||||||
|
entry.offset.to_string(),
|
||||||
|
recorded["offset"].as_str().expect("offset")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entry.byte_length.to_string(),
|
||||||
|
recorded["byteLength"].as_str().expect("byteLength")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
checkpoint::hex(&entry.digest),
|
||||||
|
recorded["digest"].as_str().expect("digest")
|
||||||
|
);
|
||||||
|
assert_eq!(entry.offset % 8, 0, "payloads start on an eight-byte boundary");
|
||||||
|
}
|
||||||
|
|
||||||
|
for payload in file["payloads"].as_array().expect("payloads") {
|
||||||
|
let name = payload["name"].as_str().expect("name");
|
||||||
|
let expected = fixtures::decode_base64(payload["base64"].as_str().expect("base64"))
|
||||||
|
.expect("base64");
|
||||||
|
assert_eq!(
|
||||||
|
envelope.payload(name).expect("a payload"),
|
||||||
|
expected.as_slice(),
|
||||||
|
"payload {name} must come back byte for byte"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
envelope.manifest,
|
||||||
|
file["manifest"],
|
||||||
|
"the manifest round trips as canonical JSON"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_recorded_corruption_is_refused() {
|
||||||
|
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
|
||||||
|
let bytes = envelope_bytes(&file);
|
||||||
|
for case in file["corruption"].as_array().expect("corruption") {
|
||||||
|
let name = case["name"].as_str().expect("name");
|
||||||
|
let offset = case["offset"].as_u64().expect("offset") as usize;
|
||||||
|
let mut corrupted = bytes.clone();
|
||||||
|
corrupted[offset] ^= 0x01;
|
||||||
|
assert!(
|
||||||
|
checkpoint::decode(&corrupted).is_err(),
|
||||||
|
"{name} must be refused: {}",
|
||||||
|
case["reason"].as_str().unwrap_or("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let truncated = &bytes[..bytes.len() - 1];
|
||||||
|
assert!(
|
||||||
|
checkpoint::decode(truncated).is_err(),
|
||||||
|
"a truncated envelope must be refused"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
checkpoint::decode(&bytes[..8]).is_err(),
|
||||||
|
"a header alone is not an envelope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flysim01_envelope_is_not_read_as_a_session_checkpoint() {
|
||||||
|
// The historical envelope: magic, u32 manifest length, manifest, chunks, CRC32.
|
||||||
|
let mut legacy = Vec::new();
|
||||||
|
legacy.extend_from_slice(b"FLYSIM01");
|
||||||
|
let manifest = br#"{"schemaVersion":2,"chunks":[]}"#;
|
||||||
|
legacy.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
|
||||||
|
legacy.extend_from_slice(manifest);
|
||||||
|
legacy.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
assert!(
|
||||||
|
checkpoint::decode(&legacy).is_err(),
|
||||||
|
"FLYSESS1 is a new format; the old reader stays separate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_layout_is_deterministic_and_the_manifest_is_canonical() {
|
||||||
|
let manifest = canonical::parse_strict(br#"{"b":2,"a":1}"#).expect("parses");
|
||||||
|
let payloads = vec![
|
||||||
|
("one".to_owned(), b"first".to_vec()),
|
||||||
|
("two".to_owned(), vec![0u8; 9]),
|
||||||
|
];
|
||||||
|
let bytes = checkpoint::encode(&manifest, &payloads).expect("encode");
|
||||||
|
let again = checkpoint::encode(&manifest, &payloads).expect("encode");
|
||||||
|
assert_eq!(bytes, again, "the same inputs produce the same bytes");
|
||||||
|
let envelope = checkpoint::decode(&bytes).expect("decode");
|
||||||
|
let start = checkpoint::HEADER_BYTES;
|
||||||
|
let end = start + envelope.layout.manifest_bytes as usize;
|
||||||
|
assert_eq!(
|
||||||
|
std::str::from_utf8(&bytes[start..end]).expect("utf-8"),
|
||||||
|
"{\"a\":1,\"b\":2}",
|
||||||
|
"the manifest is stored as canonical JSON"
|
||||||
|
);
|
||||||
|
assert_eq!(envelope.layout.entries[1].offset % 8, 0);
|
||||||
|
assert!(
|
||||||
|
checkpoint::encode(
|
||||||
|
&manifest,
|
||||||
|
&[("one".to_owned(), vec![]), ("one".to_owned(), vec![])]
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"payload names are unique"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
checkpoint::encode(&manifest, &[("One".to_owned(), vec![])]).is_err(),
|
||||||
|
"payload names are Ids, and the widening from letters-only is deliberate, not arbitrary"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
checkpoint::encode(&manifest, &[("a".to_owned(), Vec::new())]).is_ok(),
|
||||||
|
"an empty payload is still a payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_manifest_missing_a_required_field_is_not_a_complete_checkpoint() {
|
||||||
|
let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json");
|
||||||
|
let full = file["manifest"].clone();
|
||||||
|
for field in checkpoint::REQUIRED_MANIFEST_FIELDS {
|
||||||
|
let mut manifest = full.clone();
|
||||||
|
manifest.as_object_mut().expect("object").remove(*field);
|
||||||
|
let bytes = checkpoint::encode(&manifest, &[]).expect("encode");
|
||||||
|
let envelope = checkpoint::decode(&bytes).expect("decode");
|
||||||
|
assert!(
|
||||||
|
checkpoint::validate_manifest(&envelope).is_err(),
|
||||||
|
"a manifest without {field:?} must be refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
services/flysim/crates/fly-session-types/tests/common/mod.rs
Normal file
133
services/flysim/crates/fly-session-types/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! One place that knows how to read every type named by a fixture.
|
||||||
|
|
||||||
|
use flybus::wire::WireError;
|
||||||
|
use fly_session_types::media::*;
|
||||||
|
use fly_session_types::publishing::*;
|
||||||
|
use fly_session_types::rpc::*;
|
||||||
|
use fly_session_types::scalar::*;
|
||||||
|
use fly_session_types::trace::*;
|
||||||
|
use fly_session_types::workers::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Reads the value as `type_name`, re-runs its validate step and writes it back out.
|
||||||
|
///
|
||||||
|
/// Every fixture assertion goes through this, so a type that reads a field but forgets to
|
||||||
|
/// write it back fails the round trip.
|
||||||
|
pub fn round_trip(type_name: &str, value: &Value) -> std::result::Result<Value, WireError> {
|
||||||
|
macro_rules! arm {
|
||||||
|
($t:ty) => {
|
||||||
|
if type_name == <$t as DomainType>::TYPE_NAME {
|
||||||
|
let parsed = <$t as DomainType>::from_json(value)?;
|
||||||
|
parsed.validate()?;
|
||||||
|
return Ok(parsed.to_json());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
arm!(Scope);
|
||||||
|
arm!(RationalNs);
|
||||||
|
arm!(SchemaRef);
|
||||||
|
arm!(TypedValue);
|
||||||
|
arm!(SessionRpcRequest);
|
||||||
|
arm!(SessionRpcSuccess);
|
||||||
|
arm!(SessionRpcFailure);
|
||||||
|
arm!(AssetRef);
|
||||||
|
arm!(SensoryInput);
|
||||||
|
arm!(Stimulus);
|
||||||
|
arm!(Reward);
|
||||||
|
arm!(AgentTelemetry);
|
||||||
|
arm!(AgentInitializeParams);
|
||||||
|
arm!(AgentInitializeResult);
|
||||||
|
arm!(PrepareParams);
|
||||||
|
arm!(PreparedDecision);
|
||||||
|
arm!(CommitParams);
|
||||||
|
arm!(AgentCommitResult);
|
||||||
|
arm!(ControllerSchema);
|
||||||
|
arm!(PortControl);
|
||||||
|
arm!(EnvironmentDescriptor);
|
||||||
|
arm!(EnvironmentInitializeParams);
|
||||||
|
arm!(EnvironmentInitializeResult);
|
||||||
|
arm!(WorldObservation);
|
||||||
|
arm!(AdvanceParams);
|
||||||
|
arm!(StepResult);
|
||||||
|
arm!(HelloParams);
|
||||||
|
arm!(HelloResult);
|
||||||
|
arm!(StatusResult);
|
||||||
|
arm!(AcknowledgeParams);
|
||||||
|
arm!(AcknowledgeResult);
|
||||||
|
arm!(ShutdownParams);
|
||||||
|
arm!(ShutdownResult);
|
||||||
|
arm!(TaskEvent);
|
||||||
|
arm!(EpisodeRequest);
|
||||||
|
arm!(ViewDescriptor);
|
||||||
|
arm!(ViewRef);
|
||||||
|
arm!(AudioDescriptor);
|
||||||
|
arm!(AudioRef);
|
||||||
|
arm!(CaptureParams);
|
||||||
|
arm!(CaptureResult);
|
||||||
|
arm!(StageRestoreParams);
|
||||||
|
arm!(StageRestoreResult);
|
||||||
|
arm!(ActivateRestoreParams);
|
||||||
|
arm!(ActivateRestoreResult);
|
||||||
|
arm!(SessionDescriptor);
|
||||||
|
arm!(CommittedSnapshot);
|
||||||
|
arm!(TraceBehaviour);
|
||||||
|
arm!(TraceOperational);
|
||||||
|
arm!(TransitionTrace);
|
||||||
|
Err(WireError(format!(
|
||||||
|
"no fixture reader for type {type_name:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type names `round_trip` knows.
|
||||||
|
pub const READABLE_TYPES: &[&str] = &[
|
||||||
|
"Scope",
|
||||||
|
"RationalNs",
|
||||||
|
"SchemaRef",
|
||||||
|
"TypedValue",
|
||||||
|
"SessionRpcRequest",
|
||||||
|
"SessionRpcSuccess",
|
||||||
|
"SessionRpcFailure",
|
||||||
|
"AssetRef",
|
||||||
|
"SensoryInput",
|
||||||
|
"Stimulus",
|
||||||
|
"Reward",
|
||||||
|
"AgentTelemetry",
|
||||||
|
"AgentInitializeParams",
|
||||||
|
"AgentInitializeResult",
|
||||||
|
"PrepareParams",
|
||||||
|
"PreparedDecision",
|
||||||
|
"CommitParams",
|
||||||
|
"AgentCommitResult",
|
||||||
|
"ControllerSchema",
|
||||||
|
"PortControl",
|
||||||
|
"EnvironmentDescriptor",
|
||||||
|
"EnvironmentInitializeParams",
|
||||||
|
"EnvironmentInitializeResult",
|
||||||
|
"WorldObservation",
|
||||||
|
"AdvanceParams",
|
||||||
|
"StepResult",
|
||||||
|
"HelloParams",
|
||||||
|
"HelloResult",
|
||||||
|
"StatusResult",
|
||||||
|
"AcknowledgeParams",
|
||||||
|
"AcknowledgeResult",
|
||||||
|
"ShutdownParams",
|
||||||
|
"ShutdownResult",
|
||||||
|
"TaskEvent",
|
||||||
|
"EpisodeRequest",
|
||||||
|
"ViewDescriptor",
|
||||||
|
"ViewRef",
|
||||||
|
"AudioDescriptor",
|
||||||
|
"AudioRef",
|
||||||
|
"CaptureParams",
|
||||||
|
"CaptureResult",
|
||||||
|
"StageRestoreParams",
|
||||||
|
"StageRestoreResult",
|
||||||
|
"ActivateRestoreParams",
|
||||||
|
"ActivateRestoreResult",
|
||||||
|
"SessionDescriptor",
|
||||||
|
"CommittedSnapshot",
|
||||||
|
"TraceBehaviour",
|
||||||
|
"TraceOperational",
|
||||||
|
"TransitionTrace",
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
//! Rules that need a descriptor in hand: complete batches, the observation delay rule, byte
|
||||||
|
//! shapes and descriptor agreement.
|
||||||
|
|
||||||
|
use fly_session_types::fixtures;
|
||||||
|
use fly_session_types::publishing::{CommittedSnapshot, SessionDescriptor};
|
||||||
|
use fly_session_types::scalar::{DomainType, Result};
|
||||||
|
use fly_session_types::workers::{
|
||||||
|
EnvironmentDescriptor, PortControl, SensoryInput, StepResult, WorldObservation,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_descriptor_check_lands_the_way_the_fixture_says() {
|
||||||
|
let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json");
|
||||||
|
let descriptor =
|
||||||
|
EnvironmentDescriptor::from_json(file.get("descriptor").expect("descriptor")).expect("descriptor");
|
||||||
|
let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed"))
|
||||||
|
.expect("delayed descriptor");
|
||||||
|
let session = SessionDescriptor::from_json(file.get("sessionDescriptor").expect("session"))
|
||||||
|
.expect("session descriptor");
|
||||||
|
let previous = WorldObservation::from_json(file.get("stepResultPrevious").expect("previous"))
|
||||||
|
.expect("previous observation");
|
||||||
|
|
||||||
|
for case in fixtures::cases(&file).expect("cases") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let kind = fixtures::field(case, "kind").expect("kind");
|
||||||
|
let reason = fixtures::field(case, "reason").unwrap_or("");
|
||||||
|
let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept";
|
||||||
|
let value = case.get("value").expect("value");
|
||||||
|
let outcome: Result<()> = match kind {
|
||||||
|
"portControl" => PortControl::from_json(value).and_then(|control| {
|
||||||
|
match descriptor.port(&control.port_id) {
|
||||||
|
Some(port) => control.validate_against(&port.controls),
|
||||||
|
None => fly_session_types::scalar::err("no such port"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"advanceControls" => value
|
||||||
|
.as_array()
|
||||||
|
.expect("an array of controls")
|
||||||
|
.iter()
|
||||||
|
.map(PortControl::from_json)
|
||||||
|
.collect::<Result<Vec<_>>>()
|
||||||
|
.and_then(|controls| descriptor.validate_batch(&controls)),
|
||||||
|
"sensoryInput" => SensoryInput::from_json(value)
|
||||||
|
.and_then(|input| input.validate_against(&descriptor.views)),
|
||||||
|
"sensoryInputDelayed" => {
|
||||||
|
SensoryInput::from_json(value).and_then(|input| input.validate_against(&delayed.views))
|
||||||
|
}
|
||||||
|
"worldObservation" => WorldObservation::from_json(value)
|
||||||
|
.and_then(|observation| observation.validate_against(&descriptor)),
|
||||||
|
"stepResult" => StepResult::from_json(value)
|
||||||
|
.and_then(|result| result.validate_against(&descriptor, &previous)),
|
||||||
|
"snapshot" => CommittedSnapshot::from_json(value)
|
||||||
|
.and_then(|snapshot| snapshot.validate_against(&session)),
|
||||||
|
other => panic!("unknown descriptor check kind {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
outcome.is_ok(),
|
||||||
|
expect_accept,
|
||||||
|
"{name}: expected {}. {reason}. outcome: {outcome:?}",
|
||||||
|
if expect_accept { "accept" } else { "reject" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The delay rule itself, stated once: `max(0, boundary - observationDelaySteps)`.
|
||||||
|
#[test]
|
||||||
|
fn the_required_producing_boundary_saturates_at_zero() {
|
||||||
|
let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json");
|
||||||
|
let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed"))
|
||||||
|
.expect("delayed descriptor");
|
||||||
|
let view = &delayed.views[0];
|
||||||
|
assert_eq!(view.observation_delay_steps, 2);
|
||||||
|
assert_eq!(view.required_produced_step(0), 0);
|
||||||
|
assert_eq!(view.required_produced_step(1), 0);
|
||||||
|
assert_eq!(view.required_produced_step(2), 0);
|
||||||
|
assert_eq!(view.required_produced_step(3), 1);
|
||||||
|
assert_eq!(view.frame_bytes(), u64::from(160u32 * 4 * 144));
|
||||||
|
}
|
||||||
169
services/flysim/crates/fly-session-types/tests/encodings.rs
Normal file
169
services/flysim/crates/fly-session-types/tests/encodings.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
//! The scalar encodings agree with the bus's, and the four identities cannot be confused.
|
||||||
|
|
||||||
|
use fly_session_types::fixtures;
|
||||||
|
use fly_session_types::scalar::{
|
||||||
|
ArtifactIdentity, BusCallId, DomainRequestId, OwnerKind, OwnerToken, is_digest, is_id, parse_u64,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// The domain `Id`, `U64` and `Digest` are the bus encodings, not a second opinion about them.
|
||||||
|
#[test]
|
||||||
|
fn domain_scalars_are_the_bus_scalars() {
|
||||||
|
let ids = [
|
||||||
|
"a",
|
||||||
|
"fly-a",
|
||||||
|
"0",
|
||||||
|
"a.b_c-d",
|
||||||
|
"",
|
||||||
|
"A",
|
||||||
|
"-a",
|
||||||
|
".a",
|
||||||
|
"a b",
|
||||||
|
"fly/a",
|
||||||
|
&"a".repeat(64),
|
||||||
|
&"a".repeat(65),
|
||||||
|
];
|
||||||
|
for id in ids {
|
||||||
|
assert_eq!(
|
||||||
|
is_id(id),
|
||||||
|
flybus::wire::is_id(id),
|
||||||
|
"Id encoding must agree with the bus for {id:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let numbers = [
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"18446744073709551615",
|
||||||
|
"18446744073709551616",
|
||||||
|
"01",
|
||||||
|
"",
|
||||||
|
"-1",
|
||||||
|
"1.0",
|
||||||
|
" 1",
|
||||||
|
];
|
||||||
|
for text in numbers {
|
||||||
|
assert_eq!(
|
||||||
|
parse_u64(text),
|
||||||
|
flybus::wire::parse_u64(text),
|
||||||
|
"U64 encoding must agree with the bus for {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let digests = [
|
||||||
|
&"a".repeat(64),
|
||||||
|
&"0".repeat(64),
|
||||||
|
&"A".repeat(64),
|
||||||
|
&"g".repeat(64),
|
||||||
|
&"a".repeat(63),
|
||||||
|
];
|
||||||
|
for digest in digests {
|
||||||
|
assert_eq!(
|
||||||
|
is_digest(digest),
|
||||||
|
flybus::wire::is_digest(digest),
|
||||||
|
"Digest encoding must agree with the bus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u64_boundaries_reject_from_the_fixture() {
|
||||||
|
let file = fixtures::load("boundaries.json").expect("boundaries.json");
|
||||||
|
let cases = file.get("u64").and_then(Value::as_array).expect("u64");
|
||||||
|
for case in cases {
|
||||||
|
let text = fixtures::field(case, "text").expect("text");
|
||||||
|
let accept = case.get("accept").and_then(Value::as_bool).expect("accept");
|
||||||
|
assert_eq!(
|
||||||
|
parse_u64(text).is_some(),
|
||||||
|
accept,
|
||||||
|
"{text:?}: {}",
|
||||||
|
fixtures::field(case, "reason").unwrap_or("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bus callId, domain requestId and delivery/hold owner tokens are four types. The fixture
|
||||||
|
/// says, for every spelling, which of them accept it: no string is accepted by two.
|
||||||
|
#[test]
|
||||||
|
fn the_four_identities_never_accept_each_others_spellings() {
|
||||||
|
let file = fixtures::load("identities.json").expect("identities.json");
|
||||||
|
for case in fixtures::cases(&file).expect("cases") {
|
||||||
|
let text = fixtures::field(case, "text").expect("text");
|
||||||
|
let call = case.get("busCallId").and_then(Value::as_bool).expect("busCallId");
|
||||||
|
let request = case
|
||||||
|
.get("domainRequestId")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.expect("domainRequestId");
|
||||||
|
let owner = case.get("ownerToken").expect("ownerToken");
|
||||||
|
assert_eq!(BusCallId::parse(text).is_ok(), call, "busCallId {text:?}");
|
||||||
|
assert_eq!(
|
||||||
|
DomainRequestId::parse(text).is_ok(),
|
||||||
|
request,
|
||||||
|
"domainRequestId {text:?}"
|
||||||
|
);
|
||||||
|
match owner {
|
||||||
|
Value::Null => assert!(
|
||||||
|
OwnerToken::parse(text).is_err(),
|
||||||
|
"owner token {text:?} must be refused"
|
||||||
|
),
|
||||||
|
Value::String(kind) => {
|
||||||
|
let parsed = OwnerToken::parse(text).expect("an owner token");
|
||||||
|
let expected = match kind.as_str() {
|
||||||
|
"delivery" => OwnerKind::Delivery,
|
||||||
|
"hold" => OwnerKind::Hold,
|
||||||
|
other => panic!("unknown owner kind {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.kind(), expected, "owner kind of {text:?}");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected ownerToken field {other:?}"),
|
||||||
|
}
|
||||||
|
let accepted = [call, request, OwnerToken::parse(text).is_ok()]
|
||||||
|
.iter()
|
||||||
|
.filter(|a| **a)
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
accepted <= 1,
|
||||||
|
"{text:?} is accepted by more than one identity type"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An artifact identity is the naming half of a bus ArtifactRef, and nothing else in these
|
||||||
|
/// contracts is one: an AssetRef is persistent installed content, not live bytes.
|
||||||
|
#[test]
|
||||||
|
fn artifact_identity_is_the_naming_half_of_an_artifact_ref() {
|
||||||
|
let file = fixtures::load("identities.json").expect("identities.json");
|
||||||
|
let section = file.get("artifact").expect("artifact");
|
||||||
|
let reference =
|
||||||
|
flybus::wire::ArtifactRef::from_json(section.get("ref").expect("ref")).expect("a ref");
|
||||||
|
let identity = ArtifactIdentity::of(&reference);
|
||||||
|
identity.validate().expect("a valid identity");
|
||||||
|
let expected = section.get("identity").expect("identity");
|
||||||
|
assert_eq!(identity.store_id, expected["storeId"].as_str().unwrap());
|
||||||
|
assert_eq!(identity.artifact_id, expected["artifactId"].as_str().unwrap());
|
||||||
|
assert_eq!(identity.generation.to_string(), expected["generation"].as_str().unwrap());
|
||||||
|
|
||||||
|
let asset = fly_session_types::workers::AssetRef::from_json(section_asset(&file)).expect("asset");
|
||||||
|
assert_ne!(
|
||||||
|
asset.id, identity.artifact_id,
|
||||||
|
"the fixture's asset and artifact are deliberately different things"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_asset(file: &Value) -> &Value {
|
||||||
|
file.get("asset").expect("asset")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delivery id or hold token is connection-private. The domain reader has no field that
|
||||||
|
/// takes one, which is what `canonical::reject_bus_identities` enforces; here we only pin
|
||||||
|
/// that the two prefixes the bus issues are the two kinds this type knows.
|
||||||
|
#[test]
|
||||||
|
fn owner_tokens_come_in_exactly_two_kinds() {
|
||||||
|
assert_eq!(OwnerToken::delivery(7).as_str(), "dlv-7");
|
||||||
|
assert_eq!(OwnerToken::hold(9).as_str(), "own-9");
|
||||||
|
assert_eq!(OwnerToken::delivery(7).kind(), OwnerKind::Delivery);
|
||||||
|
assert_eq!(OwnerToken::hold(9).kind(), OwnerKind::Hold);
|
||||||
|
assert_eq!(BusCallId::from_serial(12).as_str(), "call-12");
|
||||||
|
assert_eq!(DomainRequestId::from_serial(41).as_str(), "req-41");
|
||||||
|
assert_eq!(DomainRequestId::from_serial(41).serial(), 41);
|
||||||
|
}
|
||||||
|
|
||||||
|
use fly_session_types::scalar::DomainType as _;
|
||||||
177
services/flysim/crates/fly-session-types/tests/payloads.rs
Normal file
177
services/flysim/crates/fly-session-types/tests/payloads.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
//! The `valid.json`, `invalid.json`, `raw.json` and `generated.json` fixtures.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use fly_session_types::scalar::{DomainType, MAX_TYPED_VALUE_BYTES, SchemaRef, TypedValue};
|
||||||
|
use fly_session_types::{canonical, fixtures};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use common::round_trip;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_valid_case_round_trips_and_canonicalizes_to_its_recorded_bytes() {
|
||||||
|
let file = fixtures::load("valid.json").expect("valid.json");
|
||||||
|
let cases = fixtures::cases(&file).expect("cases");
|
||||||
|
for case in cases {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let type_name = fixtures::field(case, "type").expect("type");
|
||||||
|
let value = case.get("value").expect("value");
|
||||||
|
let written = round_trip(type_name, value)
|
||||||
|
.unwrap_or_else(|e| panic!("{name} ({type_name}) must be accepted: {e}"));
|
||||||
|
let canonical_in = canonical::canonicalize(value).expect("canonicalizable");
|
||||||
|
let canonical_out = canonical::canonicalize(&written).expect("canonicalizable");
|
||||||
|
assert_eq!(
|
||||||
|
canonical_in, canonical_out,
|
||||||
|
"{name}: reading and writing must preserve every field"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_in,
|
||||||
|
fixtures::field(case, "canonical").expect("canonical"),
|
||||||
|
"{name}: canonical JSON must match the fixture"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical::sha256_hex(canonical_in.as_bytes()),
|
||||||
|
fixtures::field(case, "digest").expect("digest"),
|
||||||
|
"{name}: digest must match the fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(cases.len() >= 70, "the valid fixture should stay broad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_type_the_readers_know_appears_in_the_valid_fixture() {
|
||||||
|
let file = fixtures::load("valid.json").expect("valid.json");
|
||||||
|
let cases = fixtures::cases(&file).expect("cases");
|
||||||
|
let covered: Vec<&str> = cases
|
||||||
|
.iter()
|
||||||
|
.map(|case| fixtures::field(case, "type").expect("type"))
|
||||||
|
.collect();
|
||||||
|
let missing: Vec<&&str> = common::READABLE_TYPES
|
||||||
|
.iter()
|
||||||
|
.filter(|t| !covered.contains(*t))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"every readable type needs at least one accepted fixture; missing {missing:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_invalid_case_is_refused() {
|
||||||
|
let file = fixtures::load("invalid.json").expect("invalid.json");
|
||||||
|
let cases = fixtures::cases(&file).expect("cases");
|
||||||
|
for case in cases {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let type_name = fixtures::field(case, "type").expect("type");
|
||||||
|
let reason = fixtures::field(case, "reason").expect("reason");
|
||||||
|
let value = case.get("value").expect("value");
|
||||||
|
let outcome = round_trip(type_name, value);
|
||||||
|
assert!(
|
||||||
|
outcome.is_err(),
|
||||||
|
"{name} ({type_name}) must be refused: {reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(cases.len() >= 80, "the invalid fixture should stay broad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_raw_byte_case_is_refused_before_or_during_validation() {
|
||||||
|
let file = fixtures::load("raw.json").expect("raw.json");
|
||||||
|
for case in fixtures::cases(&file).expect("cases") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let type_name = fixtures::field(case, "type").expect("type");
|
||||||
|
let reason = fixtures::field(case, "reason").expect("reason");
|
||||||
|
let bytes = fixtures::base64(case, "base64").unwrap_or_default();
|
||||||
|
let outcome = canonical::parse_strict(&bytes).and_then(|value| {
|
||||||
|
round_trip(type_name, &value).map_err(|e| fly_session_types::scalar::wire_err(e.0))
|
||||||
|
});
|
||||||
|
assert!(outcome.is_err(), "{name} must be refused: {reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recipes in `generated.json`: payloads too large to store as fixtures.
|
||||||
|
#[test]
|
||||||
|
fn generated_boundary_cases_land_on_the_right_side_of_every_limit() {
|
||||||
|
let file = fixtures::load("generated.json").expect("generated.json");
|
||||||
|
let pad_schema = SchemaRef::from_json(file.get("padSchema").expect("padSchema")).expect("schema");
|
||||||
|
for case in fixtures::cases(&file).expect("cases") {
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let kind = fixtures::field(case, "kind").expect("kind");
|
||||||
|
let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept";
|
||||||
|
let outcome: Result<(), String> = match kind {
|
||||||
|
"padded-typed-value" => {
|
||||||
|
let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize;
|
||||||
|
TypedValue::new(pad_schema.clone(), json!({"pad": "a".repeat(pad)}))
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.0)
|
||||||
|
}
|
||||||
|
"padded-request" => {
|
||||||
|
let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize;
|
||||||
|
let total = case
|
||||||
|
.get("envelopeTotal")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.expect("envelopeTotal") as usize;
|
||||||
|
let request = json!({
|
||||||
|
"requestId": "req-1",
|
||||||
|
"scope": Value::Null,
|
||||||
|
"params": {"pad": "a".repeat(pad)},
|
||||||
|
});
|
||||||
|
let body = round_trip("SessionRpcRequest", &request).expect("a request");
|
||||||
|
let length = canonical::canonicalize(&body).expect("canonicalizable").len();
|
||||||
|
canonical::require_envelope_fit(&body, total - length)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.0)
|
||||||
|
}
|
||||||
|
"error-message" | "error-message-astral" => {
|
||||||
|
let points = case.get("codePoints").and_then(Value::as_u64).expect("codePoints")
|
||||||
|
as usize;
|
||||||
|
let character = if kind == "error-message" { 'x' } else { '\u{10400}' };
|
||||||
|
let message: String = std::iter::repeat_n(character, points).collect();
|
||||||
|
let failure = json!({
|
||||||
|
"type": "error",
|
||||||
|
"requestId": "req-41",
|
||||||
|
"workerId": "fly-a",
|
||||||
|
"incarnationId": "inc-1",
|
||||||
|
"scope": Value::Null,
|
||||||
|
"error": {"code": "INTERNAL", "message": message, "mutation": "unknown"},
|
||||||
|
});
|
||||||
|
round_trip("SessionRpcFailure", &failure)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.0)
|
||||||
|
}
|
||||||
|
other => panic!("unknown generated case kind {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
outcome.is_ok(),
|
||||||
|
expect_accept,
|
||||||
|
"{name}: expected {}, got {outcome:?}",
|
||||||
|
if expect_accept { "accept" } else { "reject" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_typed_value_at_the_cap_is_accepted_and_one_byte_more_is_not() {
|
||||||
|
let schema = SchemaRef::new("pad.v1", 1, &canonical::sha256_hex(b"pad.v1")).expect("schema");
|
||||||
|
let overhead = canonical::canonicalize(
|
||||||
|
&TypedValue::new(schema.clone(), json!({"pad": ""}))
|
||||||
|
.expect("empty")
|
||||||
|
.to_json(),
|
||||||
|
)
|
||||||
|
.expect("canonicalizable")
|
||||||
|
.len();
|
||||||
|
let at_cap = TypedValue::new(
|
||||||
|
schema.clone(),
|
||||||
|
json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead)}),
|
||||||
|
)
|
||||||
|
.expect("exactly at the cap");
|
||||||
|
assert_eq!(at_cap.canonical_len().expect("length"), MAX_TYPED_VALUE_BYTES);
|
||||||
|
assert!(
|
||||||
|
TypedValue::new(
|
||||||
|
schema,
|
||||||
|
json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead + 1)})
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"one byte over the cap must fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
140
services/flysim/crates/fly-session-types/tests/rational.rs
Normal file
140
services/flysim/crates/fly-session-types/tests/rational.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
//! Checked rational arithmetic and the step-v1 section 5 tick accumulator.
|
||||||
|
|
||||||
|
use fly_session_types::scalar::{DomainType, RationalNs};
|
||||||
|
use fly_session_types::fixtures;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn rational(value: &Value) -> RationalNs {
|
||||||
|
RationalNs::from_json(value).expect("a valid rational")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_accumulator_produces_the_fixture_tick_counts_and_remainders() {
|
||||||
|
let file = fixtures::load("rational.json").expect("rational.json");
|
||||||
|
for case in file
|
||||||
|
.get("accumulator")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("accumulator")
|
||||||
|
{
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let step = rational(case.get("stepDuration").expect("stepDuration"));
|
||||||
|
let tick = rational(case.get("tickDuration").expect("tickDuration"));
|
||||||
|
let mut accumulator = RationalNs::ZERO;
|
||||||
|
let mut total = 0u64;
|
||||||
|
for (index, expected) in case
|
||||||
|
.get("steps")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("steps")
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
accumulator = accumulator.checked_add(&step).expect("checked add");
|
||||||
|
let (ticks, remainder) = accumulator.divide_floor(&tick).expect("checked divide");
|
||||||
|
accumulator = remainder;
|
||||||
|
total += ticks;
|
||||||
|
assert_eq!(
|
||||||
|
ticks.to_string(),
|
||||||
|
fixtures::field(expected, "ticks").expect("ticks"),
|
||||||
|
"{name}: tick count at step {index}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remainder,
|
||||||
|
rational(expected.get("remainder").expect("remainder")),
|
||||||
|
"{name}: remainder at step {index}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
remainder < tick,
|
||||||
|
"{name}: the remainder is always less than one model tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
total.to_string(),
|
||||||
|
fixtures::field(case, "totalTicks").expect("totalTicks"),
|
||||||
|
"{name}: total ticks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_arithmetic_reduces_or_refuses() {
|
||||||
|
let file = fixtures::load("rational.json").expect("rational.json");
|
||||||
|
for case in file.get("add").and_then(Value::as_array).expect("add") {
|
||||||
|
let outcome = rational(case.get("a").expect("a")).checked_add(&rational(case.get("b").expect("b")));
|
||||||
|
match case.get("sum") {
|
||||||
|
Some(sum) => assert_eq!(outcome.expect("a sum"), rational(sum)),
|
||||||
|
None => assert!(outcome.is_err(), "the sum must overflow: {case}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for case in file
|
||||||
|
.get("subtract")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("subtract")
|
||||||
|
{
|
||||||
|
let outcome =
|
||||||
|
rational(case.get("a").expect("a")).checked_sub(&rational(case.get("b").expect("b")));
|
||||||
|
match case.get("difference") {
|
||||||
|
Some(difference) => assert_eq!(outcome.expect("a difference"), rational(difference)),
|
||||||
|
None => assert!(outcome.is_err(), "the subtraction must fail: {case}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for case in file
|
||||||
|
.get("multiply")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("multiply")
|
||||||
|
{
|
||||||
|
let k: u64 = fixtures::field(case, "k")
|
||||||
|
.expect("k")
|
||||||
|
.parse()
|
||||||
|
.expect("a u64");
|
||||||
|
let outcome = rational(case.get("a").expect("a")).checked_mul_u64(k);
|
||||||
|
match case.get("product") {
|
||||||
|
Some(product) => assert_eq!(outcome.expect("a product"), rational(product)),
|
||||||
|
None => assert!(outcome.is_err(), "the product must overflow: {case}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for case in file
|
||||||
|
.get("compare")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("compare")
|
||||||
|
{
|
||||||
|
let left = rational(case.get("a").expect("a"));
|
||||||
|
let right = rational(case.get("b").expect("b"));
|
||||||
|
let ordering = match fixtures::field(case, "ordering").expect("ordering") {
|
||||||
|
"less" => std::cmp::Ordering::Less,
|
||||||
|
"equal" => std::cmp::Ordering::Equal,
|
||||||
|
"greater" => std::cmp::Ordering::Greater,
|
||||||
|
other => panic!("unknown ordering {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(left.cmp(&right), ordering, "{case}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_has_exactly_one_encoding_and_durations_must_be_positive() {
|
||||||
|
assert_eq!(RationalNs::ZERO, RationalNs::new(0, 1).expect("0/1"));
|
||||||
|
assert!(RationalNs::new(0, 2).is_err(), "zero is encoded 0/1");
|
||||||
|
assert!(RationalNs::new(1, 0).is_err(), "denominators are positive");
|
||||||
|
assert!(RationalNs::new(2, 4).is_err(), "fractions are reduced");
|
||||||
|
assert!(RationalNs::ZERO.require_positive("worldTime").is_err());
|
||||||
|
assert!(
|
||||||
|
RationalNs::new(1, 3)
|
||||||
|
.expect("1/3")
|
||||||
|
.require_positive("tickDuration")
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
RationalNs::ZERO.divide_floor(&RationalNs::ZERO).is_err(),
|
||||||
|
"dividing by a zero tick is refused, not infinite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduction_refuses_a_result_that_does_not_fit_u64() {
|
||||||
|
let big = RationalNs::new(u64::MAX, 1).expect("a whole number");
|
||||||
|
assert!(big.checked_mul_u64(2).is_err());
|
||||||
|
assert!(big.checked_add(&big).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
RationalNs::reduced(u128::from(u64::MAX) * 2, 2).expect("reduces back into range"),
|
||||||
|
big
|
||||||
|
);
|
||||||
|
}
|
||||||
187
services/flysim/crates/fly-session-types/tests/schema_set.rs
Normal file
187
services/flysim/crates/fly-session-types/tests/schema_set.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
//! The canonical schema set, `contractDigest` and the freshness of every derived fixture.
|
||||||
|
|
||||||
|
use fly_session_types::{canonical, fixtures, schema};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[path = "../examples/update_fixtures.rs"]
|
||||||
|
#[allow(dead_code, reason = "the example's main is not used by the test that reuses its writers")]
|
||||||
|
mod updater;
|
||||||
|
|
||||||
|
/// Every derived fixture is exactly what the updater writes today. If this fails, run
|
||||||
|
/// `cargo run -p fly-session-types --example update_fixtures` and review the diff.
|
||||||
|
#[test]
|
||||||
|
fn derived_fixtures_are_current() {
|
||||||
|
for (name, expected) in updater::derived() {
|
||||||
|
let path = fixtures::dir().join(&name);
|
||||||
|
let found = std::fs::read_to_string(&path).expect("a checked-in fixture");
|
||||||
|
assert_eq!(
|
||||||
|
found, expected,
|
||||||
|
"{name} is stale; regenerate it with the update_fixtures example"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_contract_digest_is_the_digest_of_the_checked_in_schema_set() {
|
||||||
|
let text = fixtures::load_bytes("schema-set.json").expect("schema-set.json");
|
||||||
|
let recorded = fixtures::load("contract-digest.json").expect("contract-digest.json");
|
||||||
|
let expected = recorded
|
||||||
|
.get("contractDigest")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("contractDigest");
|
||||||
|
assert_eq!(schema::contract_digest(), expected);
|
||||||
|
// The file is the canonical schema set plus one trailing newline.
|
||||||
|
assert_eq!(
|
||||||
|
canonical::sha256_hex(text.strip_suffix(b"\n").expect("trailing newline")),
|
||||||
|
expected,
|
||||||
|
"the digest is over the canonical schema set, byte for byte"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digest comes from the schema declaration, not from the source text: reparsing the
|
||||||
|
/// checked-in file with different whitespace and key order gives the same digest.
|
||||||
|
#[test]
|
||||||
|
fn the_contract_digest_survives_reformatting() {
|
||||||
|
let bytes = fixtures::load_bytes("schema-set.json").expect("schema-set.json");
|
||||||
|
let parsed = canonical::parse_strict(bytes.strip_suffix(b"\n").expect("newline")).expect("parses");
|
||||||
|
let pretty = serde_json::to_vec_pretty(&parsed).expect("serializable");
|
||||||
|
let reparsed = canonical::parse_strict(&pretty).expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
canonical::digest_of(&reparsed).expect("digest"),
|
||||||
|
schema::contract_digest(),
|
||||||
|
"pretty printing the schema set does not change its digest"
|
||||||
|
);
|
||||||
|
let shuffled = canonical::parse_strict(
|
||||||
|
br#"{"version":1,"contract":"fly-session-types-other"}"#,
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
assert_ne!(
|
||||||
|
canonical::digest_of(&shuffled).expect("digest"),
|
||||||
|
schema::contract_digest()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ... and it changes when a schema changes: a renamed field, a widened bound, one more enum
|
||||||
|
/// member or one fewer type all move the digest.
|
||||||
|
#[test]
|
||||||
|
fn the_contract_digest_changes_when_a_schema_changes() {
|
||||||
|
let baseline = schema::contract_digest();
|
||||||
|
let mutate = |mutation: fn(&mut Value)| {
|
||||||
|
let mut set = schema::schema_set();
|
||||||
|
mutation(&mut set);
|
||||||
|
canonical::digest_of(&set).expect("digest")
|
||||||
|
};
|
||||||
|
let renamed_field = mutate(|set| {
|
||||||
|
set["types"][0]["fields"][0]["name"] = Value::String("sessionIdentifier".to_owned());
|
||||||
|
});
|
||||||
|
let widened_bound = mutate(|set| {
|
||||||
|
for limit in set["limits"].as_array_mut().expect("limits") {
|
||||||
|
if limit["name"] == Value::String("maxAgents".to_owned()) {
|
||||||
|
limit["value"] = Value::from(8u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let extra_enum_member = mutate(|set| {
|
||||||
|
set["enums"][0]["members"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("members")
|
||||||
|
.push(Value::String("s16le-interleaved".to_owned()));
|
||||||
|
});
|
||||||
|
let dropped_type = mutate(|set| {
|
||||||
|
set["types"].as_array_mut().expect("types").pop();
|
||||||
|
});
|
||||||
|
let relaxed_constraint = mutate(|set| {
|
||||||
|
set["types"][0]["fields"][0]["constraint"] = Value::String("anything".to_owned());
|
||||||
|
});
|
||||||
|
for (what, digest) in [
|
||||||
|
("a renamed field", renamed_field),
|
||||||
|
("a widened bound", widened_bound),
|
||||||
|
("an extra enum member", extra_enum_member),
|
||||||
|
("a dropped type", dropped_type),
|
||||||
|
("a relaxed constraint", relaxed_constraint),
|
||||||
|
] {
|
||||||
|
assert_ne!(digest, baseline, "{what} must change contractDigest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_schema_set_names_every_type_the_crate_reads() {
|
||||||
|
let set = schema::schema_set();
|
||||||
|
let names: Vec<&str> = set["types"]
|
||||||
|
.as_array()
|
||||||
|
.expect("types")
|
||||||
|
.iter()
|
||||||
|
.map(|t| t["name"].as_str().expect("name"))
|
||||||
|
.collect();
|
||||||
|
for expected in [
|
||||||
|
"Scope",
|
||||||
|
"RationalNs",
|
||||||
|
"TypedValue",
|
||||||
|
"SessionRpcRequest",
|
||||||
|
"SessionRpcFailure",
|
||||||
|
"PrepareParams",
|
||||||
|
"StepResult",
|
||||||
|
"ViewRef",
|
||||||
|
"AudioRef",
|
||||||
|
"CaptureResult",
|
||||||
|
"SessionDescriptor",
|
||||||
|
"CommittedSnapshot",
|
||||||
|
"TraceBehaviour",
|
||||||
|
"TraceOperational",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&expected), "the schema set must name {expected}");
|
||||||
|
}
|
||||||
|
let mut sorted = names.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
assert_eq!(names, sorted, "the rendered set is sorted by type name");
|
||||||
|
let mut unique = sorted.clone();
|
||||||
|
unique.dedup();
|
||||||
|
assert_eq!(unique.len(), names.len(), "no type is declared twice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bound the schema set publishes is the constant the code enforces, and every bound
|
||||||
|
/// this crate chose rather than read from a document says so.
|
||||||
|
#[test]
|
||||||
|
fn published_limits_match_the_constants_and_name_their_source() {
|
||||||
|
let set = schema::schema_set();
|
||||||
|
let limits = set["limits"].as_array().expect("limits");
|
||||||
|
let find = |name: &str| -> u64 {
|
||||||
|
limits
|
||||||
|
.iter()
|
||||||
|
.find(|l| l["name"] == Value::String(name.to_owned()))
|
||||||
|
.and_then(|l| l["value"].as_u64())
|
||||||
|
.unwrap_or_else(|| panic!("the schema set must publish {name}"))
|
||||||
|
};
|
||||||
|
assert_eq!(find("maxAgents"), fly_session_types::workers::MAX_AGENTS as u64);
|
||||||
|
assert_eq!(find("maxPorts"), fly_session_types::workers::MAX_PORTS as u64);
|
||||||
|
assert_eq!(
|
||||||
|
find("maxRateRoles"),
|
||||||
|
fly_session_types::workers::MAX_RATE_ROLES as u64
|
||||||
|
);
|
||||||
|
assert_eq!(find("maxViews"), fly_session_types::media::MAX_VIEWS as u64);
|
||||||
|
assert_eq!(
|
||||||
|
find("maxTypedValueBytes"),
|
||||||
|
fly_session_types::scalar::MAX_TYPED_VALUE_BYTES as u64
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
find("maxEnvelopeBytes"),
|
||||||
|
canonical::MAX_ENVELOPE_BYTES as u64
|
||||||
|
);
|
||||||
|
let crate_chosen: Vec<&str> = limits
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l["source"] == Value::String("crate".to_owned()))
|
||||||
|
.map(|l| l["name"].as_str().expect("name"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
crate_chosen,
|
||||||
|
[
|
||||||
|
"maxAssets",
|
||||||
|
"maxAudioStreams",
|
||||||
|
"maxCapabilities",
|
||||||
|
"maxSnapshotEvents",
|
||||||
|
"maxSupportedMajors",
|
||||||
|
"maxSupportedStimuli",
|
||||||
|
],
|
||||||
|
"a bound with no stated source must be declared as this crate's choice"
|
||||||
|
);
|
||||||
|
}
|
||||||
120
services/flysim/crates/fly-session-types/tests/seeds.rs
Normal file
120
services/flysim/crates/fly-session-types/tests/seeds.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
//! `seed-derivation-v1` against its test vectors.
|
||||||
|
|
||||||
|
use fly_session_types::{fixtures, seed};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_vector_derives_its_recorded_seed() {
|
||||||
|
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
|
||||||
|
assert_eq!(
|
||||||
|
file.get("algorithm").and_then(Value::as_str),
|
||||||
|
Some(seed::ALGORITHM)
|
||||||
|
);
|
||||||
|
let vectors = file.get("vectors").and_then(Value::as_array).expect("vectors");
|
||||||
|
for case in vectors {
|
||||||
|
let master: u64 = fixtures::field(case, "masterSeed")
|
||||||
|
.expect("masterSeed")
|
||||||
|
.parse()
|
||||||
|
.expect("a U64");
|
||||||
|
let agent = fixtures::field(case, "agentId").expect("agentId");
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(seed::material(master, agent).expect("material")).expect("utf-8"),
|
||||||
|
fixtures::field(case, "material").expect("material"),
|
||||||
|
"the hashed material is part of the specification"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
seed::material_digest(master, agent).expect("digest"),
|
||||||
|
fixtures::field(case, "materialDigest").expect("materialDigest")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
i64::from(seed::agent_seed(master, agent).expect("seed")),
|
||||||
|
case.get("seed").and_then(Value::as_i64).expect("seed"),
|
||||||
|
"seed for {agent} under master {master}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(vectors.len() >= 20, "keep the vector table broad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_composition_gets_independent_seeds() {
|
||||||
|
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
|
||||||
|
let composition = file.get("composition").expect("composition");
|
||||||
|
let master: u64 = fixtures::field(composition, "masterSeed")
|
||||||
|
.expect("masterSeed")
|
||||||
|
.parse()
|
||||||
|
.expect("a U64");
|
||||||
|
let ids: Vec<String> = composition
|
||||||
|
.get("agentIds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("agentIds")
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str().expect("an id").to_owned())
|
||||||
|
.collect();
|
||||||
|
let seeds = seed::composition_seeds(master, &ids).expect("seeds");
|
||||||
|
let recorded: Vec<i64> = composition
|
||||||
|
.get("seeds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("seeds")
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_i64().expect("a seed"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
seeds.iter().map(|s| i64::from(*s)).collect::<Vec<_>>(),
|
||||||
|
recorded
|
||||||
|
);
|
||||||
|
let mut unique = seeds.clone();
|
||||||
|
unique.sort_unstable();
|
||||||
|
unique.dedup();
|
||||||
|
assert_eq!(unique.len(), seeds.len(), "per-agent seeds are independent");
|
||||||
|
assert!(
|
||||||
|
seeds.iter().all(|s| *s != 0),
|
||||||
|
"a zero seed would stall an xorshift generator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_master_seed_or_agent_id_derives_a_different_seed() {
|
||||||
|
assert_ne!(
|
||||||
|
seed::agent_seed(0, "fly-a").expect("seed"),
|
||||||
|
seed::agent_seed(1, "fly-a").expect("seed")
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
seed::agent_seed(0, "fly-a").expect("seed"),
|
||||||
|
seed::agent_seed(0, "fly-b").expect("seed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
seed::agent_seed(7, "fly-a").expect("seed"),
|
||||||
|
seed::agent_seed(7, "fly-a").expect("seed"),
|
||||||
|
"the derivation is a function of its recorded inputs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_inputs_are_refused_rather_than_normalized() {
|
||||||
|
let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json");
|
||||||
|
for case in file.get("invalid").and_then(Value::as_array).expect("invalid") {
|
||||||
|
let master: u64 = fixtures::field(case, "masterSeed")
|
||||||
|
.expect("masterSeed")
|
||||||
|
.parse()
|
||||||
|
.expect("a U64");
|
||||||
|
if let Ok(agent) = fixtures::field(case, "agentId") {
|
||||||
|
assert!(
|
||||||
|
seed::agent_seed(master, agent).is_err(),
|
||||||
|
"{agent:?} must be refused: {}",
|
||||||
|
fixtures::field(case, "reason").unwrap_or("")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let ids: Vec<String> = case
|
||||||
|
.get("agentIds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("agentIds")
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str().expect("an id").to_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
seed::composition_seeds(master, &ids).is_err(),
|
||||||
|
"a repeated agent id must be refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
services/flysim/crates/fly-session-types/tests/traces.rs
Normal file
113
services/flysim/crates/fly-session-types/tests/traces.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
//! The step-v1 section 8 trace comparator: behaviour only.
|
||||||
|
|
||||||
|
use fly_session_types::fixtures;
|
||||||
|
use fly_session_types::scalar::DomainType;
|
||||||
|
use fly_session_types::trace::TransitionTrace;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn trace(value: &Value) -> TransitionTrace {
|
||||||
|
TransitionTrace::from_json(value).expect("a valid trace")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_variant_compares_the_way_the_fixture_says() {
|
||||||
|
let file = fixtures::load("traces.json").expect("traces.json");
|
||||||
|
let baseline = trace(file.get("baseline").expect("baseline"));
|
||||||
|
for case in file
|
||||||
|
.get("variants")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("variants")
|
||||||
|
{
|
||||||
|
let name = fixtures::field(case, "name").expect("name");
|
||||||
|
let variant = trace(case.get("trace").expect("trace"));
|
||||||
|
let expected = case
|
||||||
|
.get("behaviourEquals")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.expect("behaviourEquals");
|
||||||
|
let equal = baseline.behaviour_equals(&variant);
|
||||||
|
let diff = baseline.behaviour_diff(&variant);
|
||||||
|
assert_eq!(
|
||||||
|
equal, expected,
|
||||||
|
"{name}: behaviour equality. differences: {diff:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff.is_empty(),
|
||||||
|
expected,
|
||||||
|
"{name}: the diff must be empty exactly when the behaviour matches"
|
||||||
|
);
|
||||||
|
if let Ok(needle) = fixtures::field(case, "diffContains") {
|
||||||
|
assert!(
|
||||||
|
diff.iter().any(|line| line.contains(needle)),
|
||||||
|
"{name}: the diff should name {needle:?}, got {diff:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if expected {
|
||||||
|
assert_eq!(
|
||||||
|
baseline.behaviour.digest().expect("digest"),
|
||||||
|
variant.behaviour.digest().expect("digest"),
|
||||||
|
"{name}: equal behaviour has one digest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_whole_run_compares_transition_by_transition() {
|
||||||
|
let file = fixtures::load("traces.json").expect("traces.json");
|
||||||
|
let baseline = trace(file.get("baseline").expect("baseline"));
|
||||||
|
let variants = file
|
||||||
|
.get("variants")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("variants");
|
||||||
|
let reversed = trace(variants[0].get("trace").expect("trace"));
|
||||||
|
let changed = trace(
|
||||||
|
variants
|
||||||
|
.iter()
|
||||||
|
.find(|case| fixtures::field(case, "name").unwrap_or("") == "one extra neural tick")
|
||||||
|
.expect("the extra tick variant")
|
||||||
|
.get("trace")
|
||||||
|
.expect("trace"),
|
||||||
|
);
|
||||||
|
assert!(TransitionTrace::runs_equal(
|
||||||
|
&[baseline.clone(), baseline.clone()],
|
||||||
|
&[reversed, baseline.clone()]
|
||||||
|
));
|
||||||
|
assert!(!TransitionTrace::runs_equal(
|
||||||
|
std::slice::from_ref(&baseline),
|
||||||
|
&[changed]
|
||||||
|
));
|
||||||
|
let longer = [baseline.clone(), baseline.clone()];
|
||||||
|
assert!(
|
||||||
|
!TransitionTrace::runs_equal(std::slice::from_ref(&baseline), &longer),
|
||||||
|
"a run with more transitions is not the same run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operational half is recorded, and never part of the comparison.
|
||||||
|
#[test]
|
||||||
|
fn operational_metadata_is_recorded_and_excluded() {
|
||||||
|
let file = fixtures::load("traces.json").expect("traces.json");
|
||||||
|
let baseline = trace(file.get("baseline").expect("baseline"));
|
||||||
|
assert_eq!(baseline.operational.bus_call_ids.len(), 3);
|
||||||
|
assert_eq!(baseline.operational.prepare_request_ids.len(), 2);
|
||||||
|
assert_eq!(baseline.operational.delivery_ids.len(), 2);
|
||||||
|
assert!(baseline.operational.wall_time_ns > 0);
|
||||||
|
let retried = trace(
|
||||||
|
file.get("variants")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("variants")
|
||||||
|
.iter()
|
||||||
|
.find(|case| {
|
||||||
|
fixtures::field(case, "name").unwrap_or("")
|
||||||
|
== "a safe retry with fresh bus callIds, delivery ids and wall time"
|
||||||
|
})
|
||||||
|
.expect("the retry variant")
|
||||||
|
.get("trace")
|
||||||
|
.expect("trace"),
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
baseline.operational, retried.operational,
|
||||||
|
"the retry really did change the operational half"
|
||||||
|
);
|
||||||
|
assert!(baseline.behaviour_equals(&retried));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue