Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d6aea7059 | |||
| 4e4f3a7f3b | |||
| 08afef830f | |||
| f4951db649 | |||
| b19e57a6d3 | |||
| 2d9e959c56 | |||
| 9bd6936ea6 | |||
| 480f94875b | |||
| ceb1dbdb86 | |||
| f8558a12a3 | |||
| 692586d607 | |||
| bbb05b51a4 | |||
| 0f2652d7fa | |||
| a26bce249d | |||
| 96a88d3cb6 | |||
| 3389ead5a2 | |||
| c243d731eb | |||
| aa15dfb36e | |||
| bf3f610b66 | |||
| 416fd7f719 | |||
| c2f251cb26 | |||
| 4e9d1dbd23 | |||
| c9a8e73dbc | |||
| 8a324186cf | |||
| ee72561716 | |||
| 8018322b2a | |||
| 69f9003b9b | |||
| 995a5b88f9 | |||
| 37be2cd305 | |||
| 4674d5f283 | |||
| cc0709039f | |||
| 0ee9c0228e | |||
| 7eb0440e00 | |||
| 9c1a5dba07 | |||
| 95713735aa | |||
| fa90daf8e6 | |||
| 2f04e13ea5 | |||
| 50d8e60102 | |||
| 470c188d4c | |||
| aae8fe2571 | |||
| d6068e7c04 | |||
| 598f1db7f9 | |||
| 797255eefe | |||
| 711514602b | |||
| 9d0a30947a | |||
| 4c1f247898 | |||
| 5af0cbea65 | |||
| d91985b521 | |||
| cdc21dd396 | |||
| c6e1b41967 | |||
| c2aef3bc58 | |||
| 5f3319f4ad | |||
| 2662a34a41 | |||
| 9e7dca2716 | |||
| 288d17cad3 | |||
| a056ea814d | |||
| 05a6726738 | |||
| 071dc8e345 | |||
| aeeb36ad2a | |||
| 3b775a23c8 | |||
| 31cc69922e | |||
| e9757d7f45 | |||
| 707642bd54 | |||
| a37bd4f85f | |||
| 88a36386a5 | |||
| e43a7343f8 | |||
| 03a045de02 | |||
| 173f28b886 | |||
| ba9b1013ca | |||
| 4e43e43a26 | |||
| 2b6e15de4c | |||
| 4437afbc26 | |||
| 035a3eaa62 | |||
| eca3f53c98 | |||
| e35181b296 | |||
| 88ca0a0867 | |||
| d38db97823 | |||
| 14a914a634 | |||
| 0e590f3638 | |||
| a19fc5b494 | |||
| ab1b150d2f | |||
| 9c28a7a9ef | |||
| 63d1825628 | |||
| 8fef8bdd9e | |||
| c5a98a2884 | |||
| 7550514dcf | |||
| c64ce59c36 | |||
| 664be6e77a | |||
| 732ed009f2 | |||
| bd53de4ec6 | |||
| 4bb5cd6657 | |||
| cc5937d37c |
76 changed files with 13660 additions and 1287 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -935,6 +935,7 @@ dependencies = [
|
|||
"cultnet-rs 0.1.0",
|
||||
"huginn-mind",
|
||||
"rmp-serde",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -1177,6 +1178,7 @@ source = "git+https://eureka.gamecult.org/forge/GameCult/ModelConnector.git?rev=
|
|||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"cultcache-rs 0.3.1",
|
||||
"cultnet-rs 0.2.0",
|
||||
"rand_core 0.6.4",
|
||||
"rmp-serde",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ finding.plausible-blocker-preexisting-kinds eureka-state:finding:cut-3a.s2.F4 ep
|
|||
finding.severity-medium eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a64d656469756da5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
|
||||
finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a34c6f77a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
|
||||
ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64
|
||||
ruling.authority-steward eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f7269747981a75374657761726484a773746577617264ae406865696d64616c6c3a63686174a47265706fb047616d6543756c742f426966726f7374a5776f726473b4726566757365206c656164696e67207a65726f73a56576656e74a424657631
|
||||
cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131
|
||||
finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
|
||||
target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
|
||||
|
|
|
|||
|
|
@ -759,7 +759,6 @@ unit_enums! {
|
|||
ClaimOutcome { Holds, Falsified, Unproven }
|
||||
FindingConfidence { Confirmed, Plausible }
|
||||
FindingSeverity { Blocker, High, Medium, Low }
|
||||
RulingAuthority { Operator, Standing, Defaulted, Mind }
|
||||
FindingOrigin { Introduced, PreExisting }
|
||||
RunOperator { Operator, Mind }
|
||||
RunTurn { PersonaTurn, SelfRun, Sleep }
|
||||
|
|
@ -1127,6 +1126,55 @@ impl Bounded for ResearchSource {
|
|||
}
|
||||
}
|
||||
|
||||
/// Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is
|
||||
/// a named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's
|
||||
/// Matrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,
|
||||
/// `event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum RulingAuthority {
|
||||
Operator,
|
||||
Standing,
|
||||
Defaulted,
|
||||
Mind,
|
||||
Steward { steward: Short, repo: OrgRepo, words: Para, event: Option<Short> },
|
||||
}
|
||||
|
||||
impl RulingAuthority {
|
||||
/// The variants' spellings, in declaration order: the names the wire and listings use.
|
||||
pub const NAMES: &'static [&'static str] = &["Operator", "Standing", "Defaulted", "Mind", "Steward"];
|
||||
|
||||
/// The variant's spelling, for rows, headers and listings.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Operator => "Operator",
|
||||
Self::Standing => "Standing",
|
||||
Self::Defaulted => "Defaulted",
|
||||
Self::Mind => "Mind",
|
||||
Self::Steward { .. } => "Steward",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Bounded for RulingAuthority {
|
||||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||||
match self {
|
||||
Self::Steward { steward, repo, words, event } => {
|
||||
steward.validate(&format!("{field}.steward"))?;
|
||||
if steward.0.trim().is_empty() {
|
||||
return Err(format_error(&format!("{field}.steward"), "a steward naming no Mind"));
|
||||
}
|
||||
repo.validate(&format!("{field}.repo"))?;
|
||||
words.validate(&format!("{field}.words"))?;
|
||||
if words.0.trim().is_empty() {
|
||||
return Err(format_error(&format!("{field}.words"), "a steward ruling with no words"));
|
||||
}
|
||||
event.validate(&format!("{field}.event"))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a subject was resolved. Every referent is a parsed `PipelineRef`, so a
|
||||
/// resolution names its records by ids of the kinds they declare, validated
|
||||
/// where every other referent is; a `Fixed` commit is a `Sha` whose referent
|
||||
|
|
@ -1923,6 +1971,50 @@ mod tests {
|
|||
ruling
|
||||
}
|
||||
|
||||
fn steward_ruling() -> PipelineRuling {
|
||||
let mut ruling = mind_ruling();
|
||||
ruling.authority = RulingAuthority::Steward {
|
||||
steward: "@heimdall:chat".into(),
|
||||
repo: OrgRepo("GameCult/Bifrost".into()),
|
||||
words: "refuse leading zeros".into(),
|
||||
event: Some("$ev1".into()),
|
||||
};
|
||||
ruling
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steward_ruling_round_trips_and_leaves_the_unit_authorities_bare_strings() -> Result<()> {
|
||||
let cache = schema_cache()?;
|
||||
let document = PipelineDocument::Ruling(steward_ruling());
|
||||
document.validate()?;
|
||||
assert_eq!(PipelineDocument::decode(&document.prepare(&cache)?)?, document);
|
||||
let json = serde_json::to_value(&document)?;
|
||||
assert_eq!(json["value"]["authority"]["Steward"]["steward"], "@heimdall:chat");
|
||||
let mut unit = ruling_sample();
|
||||
unit.authority = RulingAuthority::Defaulted;
|
||||
assert_eq!(serde_json::to_value(PipelineDocument::Ruling(unit))?["value"]["authority"], "Defaulted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steward_ruling_refuses_blank_words_blank_steward_and_a_bad_repo() {
|
||||
let refused = |edit: fn(&mut RulingAuthority)| {
|
||||
let mut ruling = steward_ruling();
|
||||
edit(&mut ruling.authority);
|
||||
PipelineDocument::Ruling(ruling).validate().expect_err("refused").to_string()
|
||||
};
|
||||
let fields = [
|
||||
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = " ".into() }),
|
||||
refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }),
|
||||
refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }),
|
||||
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }),
|
||||
refused(|a| if let RulingAuthority::Steward { event, .. } = a { *event = Some("e".repeat(201).as_str().into()) }),
|
||||
];
|
||||
for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words", "authority.event"]) {
|
||||
assert!(fault.contains(field), "{fault}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A withdrawal is a pulse that says only `tempo_vote_withdrawn`: it must survive
|
||||
/// encoding to an envelope, JSON and named MessagePack as a withdrawal, and an
|
||||
/// unwithdrawn pulse must not write the key.
|
||||
|
|
@ -1967,7 +2059,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn unit_enum_names_are_the_variants_in_declaration_order() {
|
||||
assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind"]);
|
||||
assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind", "Steward"]);
|
||||
assert_eq!(FindingOrigin::NAMES, ["Introduced", "PreExisting"]);
|
||||
}
|
||||
|
||||
|
|
@ -2044,6 +2136,7 @@ mod tests {
|
|||
out.push((format!("finding.severity-{name}"), D::Finding(f)));
|
||||
}
|
||||
out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling())));
|
||||
out.push(("ruling.authority-steward".into(), D::Ruling(steward_ruling())));
|
||||
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
|
||||
spec.file_changes[0].location.symbol = Some("value_types".into());
|
||||
spec.reads = vec![
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ use crate::trouble::Trouble;
|
|||
pub const TOOL: &str = "huginn";
|
||||
/// How long one call may take, connection to last byte.
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// The environment variable a Self run sets to its run label: the session an admitting verb attributes to when `--session` is not given.
|
||||
pub const SESSION_VARIABLE: &str = "HUGINN_SESSION";
|
||||
/// `Short`'s bound in bytes (the leaf keeps it private); the daemon enforces it again.
|
||||
const SESSION_MAX: usize = 200;
|
||||
/// What `ask_until` leaves of its deadline for the caller to print and exit.
|
||||
const RESERVE: Duration = Duration::from_millis(100);
|
||||
|
||||
|
|
@ -39,6 +43,22 @@ impl Settings {
|
|||
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
|
||||
}
|
||||
|
||||
/// The session an admission is attributed to: the flag as given; else `HUGINN_SESSION` when it
|
||||
/// is set and not empty; else a refusal. Never guessed and never any other variable.
|
||||
pub fn session(&self, flag: Option<String>) -> Result<String, Trouble> {
|
||||
if let Some(flag) = flag {
|
||||
return Ok(flag);
|
||||
}
|
||||
match variable(SESSION_VARIABLE) {
|
||||
Err(detail) => Err(Trouble::invalid(detail)),
|
||||
Ok(Some(value)) if value.len() > SESSION_MAX => {
|
||||
Err(Trouble::invalid(format!("{SESSION_VARIABLE} is longer than {SESSION_MAX} bytes")))
|
||||
}
|
||||
Ok(Some(value)) if !value.is_empty() => Ok(value),
|
||||
Ok(_) => Err(Trouble::invalid(format!("--session is required (or {SESSION_VARIABLE} set to the session's label)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for another mind, read from: `instance` at `endpoint` (`rudp://<ip>:<port>`), both
|
||||
/// given as arguments. It refuses this process's own instance before any call, so the one
|
||||
/// mind a verb writes is never also its source. Every request through it declares `instance`,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! strict decode of agent-written JSON into the leaf's types (`door`), the
|
||||
//! recipe selections (`recipes`), the text rendering (`render`), the exit code
|
||||
//! and the read-back after an admission. It owns no state, writes no file and
|
||||
//! reads no configuration but two environment variables: `EUREKA_INSTANCE`
|
||||
//! reads no configuration but these environment variables: `EUREKA_INSTANCE`
|
||||
//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that
|
||||
//! holds it. Each call is a new process; there is no retry inside it, the
|
||||
//! holds it. The admitting verbs take `--session`, else `HUGINN_SESSION`. Each call is a new process; there is no retry inside it, the
|
||||
//! caller's rule retries `Unavailable` once.
|
||||
//!
|
||||
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
|
||||
|
|
@ -125,9 +125,9 @@ enum Command {
|
|||
/// The admitting agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The admitting session.
|
||||
/// The admitting session; else the environment's HUGINN_SESSION, which a Self run sets to its run.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
/// The token of the hold this exact batch got: admit it.
|
||||
#[arg(long)]
|
||||
seen: Option<String>,
|
||||
|
|
@ -152,9 +152,9 @@ enum Command {
|
|||
/// The importing agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The importing session.
|
||||
/// The importing session; else the environment's HUGINN_SESSION.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
},
|
||||
/// Recall into a dispatched brief.
|
||||
Recall {
|
||||
|
|
@ -198,9 +198,9 @@ enum PersonaCommand {
|
|||
/// The writing agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The writing session.
|
||||
/// The writing session; else the environment's HUGINN_SESSION.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
/// The stored updatedAt this put replaces; omit for the first put.
|
||||
#[arg(long)]
|
||||
expected_updated_at: Option<String>,
|
||||
|
|
@ -290,6 +290,7 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
|
|||
}
|
||||
Command::Admit { faculty, agent, session, seen, file } => admit(settings, faculty, agent, session, seen, &file),
|
||||
Command::Import { campaign, from, from_endpoint, faculty, agent, session } => {
|
||||
let session = settings.session(session)?;
|
||||
import::run(settings, import::Flags { campaign, from, from_endpoint, faculty, agent, session })
|
||||
}
|
||||
Command::Persona { command: PersonaCommand::Get } => persona_get(settings),
|
||||
|
|
@ -489,10 +490,11 @@ fn admit(
|
|||
settings: &Settings,
|
||||
faculty: Faculty,
|
||||
agent: String,
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
seen: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
let session = settings.session(session)?;
|
||||
let documents = door::documents(&door::file_or_stdin(file)?)?;
|
||||
if documents.is_empty() || documents.len() > BATCH_MAX {
|
||||
return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len())));
|
||||
|
|
@ -649,10 +651,11 @@ fn persona_put(
|
|||
settings: &Settings,
|
||||
faculty: Faculty,
|
||||
agent: String,
|
||||
session: String,
|
||||
session: Option<String>,
|
||||
expected_updated_at: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
let session = settings.session(session)?;
|
||||
let text = door::file_or_stdin(file)?;
|
||||
let state: Value = serde_json::from_str(&text)
|
||||
.map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,17 @@ pub fn header(item: &PipelineDocumentSummary) -> String {
|
|||
let _ = write!(line, " {name}");
|
||||
}
|
||||
for (key, value) in facts {
|
||||
if key != "title"
|
||||
if key == "authority"
|
||||
&& let Some(Value::Object(steward)) = value.get("Steward")
|
||||
{
|
||||
// Her words stay out of the header: it names who decided and under what.
|
||||
let _ = write!(line, " authority=Steward");
|
||||
for field in ["steward", "repo"] {
|
||||
if let Some(shown) = steward.get(field).and_then(scalar) {
|
||||
let _ = write!(line, " {field}={shown}");
|
||||
}
|
||||
}
|
||||
} else if key != "title"
|
||||
&& key != "label"
|
||||
&& let Some(shown) = scalar(value)
|
||||
{
|
||||
|
|
@ -256,6 +266,32 @@ two
|
|||
assert_eq!(serde_json::from_str::<SourceHits>(&json).unwrap(), found, "--json round-trips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steward_ruling_header_names_the_decider_and_her_stewardship_but_not_her_words() {
|
||||
use huginn_mind::eureka_pipeline::{OrgRepo, PipelineRuling, RulingAuthority};
|
||||
let mut view = viewed(None, PipelineStatus::InForce);
|
||||
view.document = PipelineDocument::Ruling(PipelineRuling {
|
||||
campaign: Slug("c".into()),
|
||||
label: "R1".into(),
|
||||
title: "Zeros".into(),
|
||||
answers: None,
|
||||
choice: None,
|
||||
ruling: "Refuse them.".into(),
|
||||
operator_quote: None,
|
||||
ruled_on: Date("2026-11-11".into()),
|
||||
precedents: vec![],
|
||||
authority: RulingAuthority::Steward {
|
||||
steward: "@heimdall:chat".into(),
|
||||
repo: OrgRepo("GameCult/Bifrost".into()),
|
||||
words: "refuse leading zeros".into(),
|
||||
event: None,
|
||||
},
|
||||
});
|
||||
let line = header(&PipelineDocumentSummary::of(&view, &|_| None));
|
||||
assert!(line.contains("authority=Steward steward=@heimdall:chat repo=GameCult/Bifrost"), "{line}");
|
||||
assert!(!line.contains("zeros"), "{line}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fact_is_one_token_or_nothing() {
|
||||
assert_eq!(scalar(&json!("")), None);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ fn run(env: &[(&str, &str)], args: &[&str], stdin: Option<&str>) -> Run {
|
|||
command
|
||||
.env_remove("EUREKA_INSTANCE")
|
||||
.env_remove("HUGINN_ENDPOINT")
|
||||
.env_remove("HUGINN_SESSION")
|
||||
.envs(env.iter().copied())
|
||||
.args(args)
|
||||
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
|
|
@ -2782,3 +2783,81 @@ fn import_wrong_endpoint_is_foreign_instance() {
|
|||
assert_eq!(own.code, 2, "{}{}", own.out, own.err);
|
||||
assert!(own.err.starts_with("InvalidInput: --from heimdall is this process's own instance"), "{}", own.err);
|
||||
}
|
||||
|
||||
/// Configured for the test instance against a daemon at `addr`, with `HUGINN_SESSION` as given.
|
||||
fn with_session(addr: SocketAddr, session: &str, args: &[&str], stdin: Option<&str>) -> Run {
|
||||
let endpoint = format!("rudp://{addr}");
|
||||
run(&[("EUREKA_INSTANCE", INSTANCE), ("HUGINN_ENDPOINT", &endpoint), ("HUGINN_SESSION", session)], args, stdin)
|
||||
}
|
||||
|
||||
fn admit_without_session(file: &str) -> Vec<&str> {
|
||||
vec!["admit", "--faculty", "Hands", "--agent", "claude", file]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_is_the_flag_else_huginn_session_else_a_refusal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let server = live();
|
||||
|
||||
// Neither: refused before any send, nothing admitted.
|
||||
let neither = at(server.addr, &admit_without_session(&file), None);
|
||||
assert_eq!(neither.code, 2, "{}", neither.err);
|
||||
assert!(neither.err.starts_with("InvalidInput: --session is required"), "{}", neither.err);
|
||||
assert_eq!(neither.out, "");
|
||||
let count = at(server.addr, &["whoami"], None);
|
||||
assert!(count.out.contains("receipts 2 "), "{}", count.out);
|
||||
|
||||
// An empty variable is absent.
|
||||
let empty = with_session(server.addr, "", &admit_without_session(&file), None);
|
||||
assert_eq!(empty.code, 2, "{}", empty.err);
|
||||
assert!(empty.err.starts_with("InvalidInput: --session is required"), "{}", empty.err);
|
||||
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 2 "));
|
||||
|
||||
// The variable alone: the receipt carries it.
|
||||
let env_only = with_session(server.addr, "mind-20261111T072944197Z", &admit_without_session(&file), None);
|
||||
assert_eq!(env_only.code, 0, "{}", env_only.err);
|
||||
let viewed = at(server.addr, &["view", &format!("{CAMPAIGN}:campaign:self")], None);
|
||||
assert!(viewed.out.contains(" Hands/claude session mind-20261111T072944197Z tool huginn "), "{}", viewed.out);
|
||||
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 3 "));
|
||||
|
||||
// The persona door takes it the same way.
|
||||
let persona_file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z"));
|
||||
let put = with_session(server.addr, "mind-run-p", &["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", &persona_file], None);
|
||||
assert_eq!(put.code, 0, "{}", put.err);
|
||||
let receipt = put.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&put.out);
|
||||
let got = at(server.addr, &["persona", "get"], None);
|
||||
assert!(got.out.contains(&format!("receipt {receipt}")), "{}", got.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flag_wins_over_huginn_session() {
|
||||
let (server, seen) = replying("admit", HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id: "mind-commit-x".into(), committed_at: "2026-11-11T00:00:00Z".into(), writes: vec![] }));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let _ = with_session(server.addr, "from-env", &admit_args(&file), None);
|
||||
let seen = seen.lock().unwrap();
|
||||
let sessions: Vec<&str> = seen
|
||||
.iter()
|
||||
.filter_map(|request| if let HuginnMindRequest::Admit(batch) = request { Some(batch.provenance.session.0.as_str()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(sessions, ["session-7"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_huginn_session_is_refused_naming_the_variable_without_echoing_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let (server, count) = counting(HuginnMindResponse::View(None), "view");
|
||||
let long = "L".repeat(201);
|
||||
let ran = with_session(server.addr, &long, &admit_without_session(&file), None);
|
||||
assert_eq!(ran.code, 2, "{}", ran.err);
|
||||
assert!(ran.err.starts_with("InvalidInput: HUGINN_SESSION is longer than 200 bytes"), "{}", ran.err);
|
||||
assert!(!ran.err.contains("LLLL"), "{}", ran.err);
|
||||
assert_eq!(*count.lock().unwrap(), 0, "nothing was sent");
|
||||
|
||||
// The bound itself is allowed.
|
||||
let live = live();
|
||||
let edge = with_session(live.addr, &"E".repeat(200), &admit_without_session(&file), None);
|
||||
assert_eq!(edge.code, 0, "{}", edge.err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ pub fn serve<I: IndexSink<OwnedRedbMessagePackBackingStore> + Send + 'static>(
|
|||
let loop_stopping = Arc::clone(&stopping);
|
||||
let registry = schema_registry().unwrap();
|
||||
let thread = std::thread::spawn(move || {
|
||||
run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()).unwrap();
|
||||
run(&mut daemon, &mut hub, ®istry, None, &loop_stopping, &ServeOptions::default()).unwrap();
|
||||
});
|
||||
Server { addr, stopping, thread: Some(thread), ended: Arc::default(), _root: Some(root) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ chrono = "0.4.44"
|
|||
cultnet-rs.workspace = true
|
||||
huginn-mind = { path = "../huginn-mind" }
|
||||
rmp-serde = "1"
|
||||
# The desk door asks the kernel who is on the other end of a unix socket; mind-body's line.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "net"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# The vector store's and the embedder's HTTP APIs are the xenos boundary, and
|
||||
# JSON exists in `index/ollama.rs` and `index/qdrant.rs` only. `ureq` 3 with
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use crate::sources::forge::HttpForge;
|
||||
use huginn_mind::desk_wire::{DeskRequest, DeskResponse};
|
||||
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
|
||||
use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
|
||||
use huginn_mind::{Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
|
||||
|
|
@ -231,6 +232,24 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
|
|||
Handled::Answered(answer)
|
||||
}
|
||||
|
||||
/// One desk request, answered by the Mind method it names. The Mind is the instance's own
|
||||
/// (`mind` is checked against it); `deciders` is this call's list, handed to the Mind and
|
||||
/// dropped. A desk answer is not indexed (`index_text` gives it no text), so nothing here tells the index.
|
||||
/// The reads answer with the whole record and nothing here gates them (ruling
|
||||
/// `desk-read-visibility-whole-record`): they are handed `deciders` and not `subject`.
|
||||
pub fn handle_desk(&mut self, request: DeskRequest, now: DateTime<Utc>) -> DeskResponse {
|
||||
if let Err(refusal) = self.mind.require_instance(request.mind()) {
|
||||
return DeskResponse::Refused(refusal);
|
||||
}
|
||||
match request {
|
||||
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
|
||||
self.mind.desk_answer(&deciders, subject, authority, request, now)
|
||||
}
|
||||
DeskRequest::Forks { deciders, status, cursor, limit, .. } => self.mind.desk_forks(&deciders, status, cursor, limit),
|
||||
DeskRequest::Fork { deciders, fork_id, .. } => self.mind.desk_fork(&deciders, &fork_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// A semantic query starts only if the mind accepts it, and the mind
|
||||
/// decides its members before the index is asked anything. A selection
|
||||
/// with no members is answered at once: nothing is embedded and the index
|
||||
|
|
@ -665,11 +684,11 @@ pub(crate) mod tests {
|
|||
Ok(Daemon::new(Mind::open(state_root, instance)?, NoIndex))
|
||||
}
|
||||
|
||||
type Seen = Vec<(Vec<String>, Vec<bool>)>;
|
||||
pub(crate) type Seen = Vec<(Vec<String>, Vec<bool>)>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingIndex {
|
||||
seen: Arc<Mutex<Seen>>,
|
||||
pub(crate) struct RecordingIndex {
|
||||
pub(crate) seen: Arc<Mutex<Seen>>,
|
||||
}
|
||||
|
||||
impl<S: MindStore> IndexSink<S> for RecordingIndex {
|
||||
|
|
|
|||
344
crates/huginn-daemon/src/desk_door.rs
Normal file
344
crates/huginn-daemon/src/desk_door.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
//! The desk door: the second door onto the one mind, a unix socket one peer may speak on.
|
||||
//!
|
||||
//! This module owns three things. Who may speak: the kernel's report of the connecting
|
||||
//! process's uid must equal the configured one, and it is asked before a byte is read, so a
|
||||
//! refused peer has nothing read and nothing written. The socket's life: bind, replace a stale
|
||||
//! one, refuse a live one or a file that is not a socket, mode 0660, unlink on drop. And the
|
||||
//! bounds: one request per connection, one reply, a header over `DESK_MAX_REQUEST_BYTES` refused
|
||||
//! before anything is allocated, one `DESK_CONNECTION_DEADLINE` for the whole connection (every
|
||||
//! read and write spends what is left of it, so a peer that trickles bytes is cut off at the
|
||||
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name (a read of one
|
||||
//! fork past it is that fork's `fork_too_large`).
|
||||
//!
|
||||
//! The trust boundary of the reads (ruling `desk-read-visibility-whole-record`). The peer-uid check
|
||||
//! IS the whole authentication: Huginn must be reachable only by Bifrost's peer-checked socket, so
|
||||
//! the configured uid is Bifrost's and no other process's. `Forks` and `Fork` return the WHOLE record
|
||||
//! (cards, outcomes, every decider's note) to whoever passes that check; nothing in Huginn strips a
|
||||
//! note or gates a read on `subject` or `deciders` (`deciders` only fills the card and the
|
||||
//! showability gate). Bifrost applies visibility at its own door (a stripped read serves `note: null`,
|
||||
//! CultLib `fork-card.md`). A second reader of this socket is therefore a note leak, and a deployment
|
||||
//! that gives the socket to any uid but Bifrost's has no reader check left. The reads carry no
|
||||
//! `authority` (a payload that names one is refused whole), and the door has no other way to ask who
|
||||
//! is reading.
|
||||
//!
|
||||
//! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is
|
||||
//! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is
|
||||
//! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE`
|
||||
//! each, four seconds, and only the configured peer can reach that.
|
||||
//!
|
||||
//! The door keeps no request id: a retry after a lost reply is a new connection with the same
|
||||
//! request, and the stored answer is what replays it, across a restart as well.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultnet_rs::{
|
||||
CultNetMessage, CultNetSchemaKind, CultNetSchemaRegistration, CultNetSchemaRegistry, CultNetTransportProfile, CultNetWireContract,
|
||||
TcpFramedTransportConnection, TcpFramedTransportProfileOptions, create_tcp_framed_transport_profile, decode_cultnet_message_from_slice,
|
||||
encode_cultnet_message_to_vec,
|
||||
};
|
||||
use huginn_mind::desk_wire::{
|
||||
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA,
|
||||
DESK_RESPONSE_SCHEMA_JSON, DESK_SERVICE_ID, DeskResponse, fork_too_large_read,
|
||||
};
|
||||
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_response};
|
||||
use huginn_mind::{MindRefusal, MindStore};
|
||||
use rustix::net::sockopt::{Timeout, set_socket_timeout};
|
||||
|
||||
use crate::daemon::{Daemon, IndexSink};
|
||||
use crate::serve::{MAX_ECHOED_FIELD_BYTES, overlong_echo};
|
||||
|
||||
/// How long one connection of the configured peer may hold the loop, reading and writing together.
|
||||
pub const DESK_CONNECTION_DEADLINE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Connections one loop pass serves, so a peer that connects in a stream cannot hold the RUDP door off.
|
||||
pub const DESK_ACCEPTS_PER_PASS: u32 = 4;
|
||||
|
||||
/// The listening socket, and the one uid that may speak on it.
|
||||
pub struct DeskDoor {
|
||||
listener: UnixListener,
|
||||
path: PathBuf,
|
||||
peer_uid: u32,
|
||||
registry: CultNetSchemaRegistry,
|
||||
}
|
||||
|
||||
/// What became of one connection. `Display` is the log line for the ones that are not answered.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Served {
|
||||
Answered,
|
||||
/// The peer's uid is not the configured one: nothing was read or written.
|
||||
Refused { uid: u32, expected: u32 },
|
||||
/// The connection ended without a reply, and why.
|
||||
Dropped(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Served {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Answered => formatter.write_str("desk door answered a peer"),
|
||||
Self::Refused { uid, expected } => write!(formatter, "desk door refused a peer with uid {uid} (expects {expected})"),
|
||||
Self::Dropped(why) => write!(formatter, "desk door dropped a connection: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two desk contracts, as the door's catalog advertises them and nothing else.
|
||||
pub fn desk_registry() -> Result<CultNetSchemaRegistry> {
|
||||
let mut registry = CultNetSchemaRegistry::new();
|
||||
for (schema_id, title, schema_json) in [
|
||||
(DESK_REQUEST_SCHEMA, "Huginn desk request", DESK_REQUEST_SCHEMA_JSON),
|
||||
(DESK_RESPONSE_SCHEMA, "Huginn desk response", DESK_RESPONSE_SCHEMA_JSON),
|
||||
] {
|
||||
registry.register(CultNetSchemaRegistration {
|
||||
schema_id: schema_id.to_string(),
|
||||
kind: CultNetSchemaKind::WireMessage,
|
||||
wire_contracts: vec![CultNetWireContract::CultNetSchemaV0],
|
||||
schema_version: Some(schema_id.to_string()),
|
||||
document_type: None,
|
||||
title: Some(title.to_string()),
|
||||
schema_json: Some(schema_json.to_string()),
|
||||
})?;
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
/// Binds the desk socket. A path that exists and is not a socket is refused; a socket that
|
||||
/// accepts a connection is a live server and is refused; a socket that refuses the connection is
|
||||
/// stale and is replaced (`Mind::open`'s exclusive lock has already shown no live daemon of this
|
||||
/// instance). The socket is then mode 0660 and non-blocking.
|
||||
pub fn bind_desk(path: &Path, peer_uid: u32) -> Result<DeskDoor> {
|
||||
ensure!(path.is_absolute(), "the desk socket path must be absolute, got {}", path.display());
|
||||
ensure!(peer_uid != 0, "the desk peer uid must not be 0");
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(found) => {
|
||||
ensure!(found.file_type().is_socket(), "{} exists and is not a socket", path.display());
|
||||
match UnixStream::connect(path) {
|
||||
Ok(_) => bail!("{} is a live socket; another server holds it", path.display()),
|
||||
Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {
|
||||
fs::remove_file(path).with_context(|| format!("removing the stale socket {}", path.display()))?;
|
||||
}
|
||||
Err(error) => return Err(error).with_context(|| format!("probing the socket {}", path.display())),
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
|
||||
}
|
||||
let listener = UnixListener::bind(path).with_context(|| format!("binding {}", path.display()))?;
|
||||
// The door owns the path from here, so a failure below still unlinks it on drop.
|
||||
let door = DeskDoor { listener, path: path.to_path_buf(), peer_uid, registry: desk_registry()? };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o660)).with_context(|| format!("setting the mode of {}", path.display()))?;
|
||||
door.listener.set_nonblocking(true)?;
|
||||
Ok(door)
|
||||
}
|
||||
|
||||
impl DeskDoor {
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Serves at most `DESK_ACCEPTS_PER_PASS` waiting connections, each to completion, and says
|
||||
/// how many it took. A refusal or a dropped connection is logged and counted: the loop was
|
||||
/// busy with it.
|
||||
pub fn serve_ready<S: MindStore, I: IndexSink<S>>(&mut self, daemon: &mut Daemon<S, I>, now: DateTime<Utc>) -> u32 {
|
||||
let mut taken = 0;
|
||||
while taken < DESK_ACCEPTS_PER_PASS {
|
||||
let stream = match self.listener.accept() {
|
||||
Ok((stream, _)) => stream,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||
Err(error) => {
|
||||
eprintln!("huginn: the desk socket could not accept: {error}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
taken += 1;
|
||||
let served = serve_connection(stream, self.peer_uid, daemon, &self.registry, now, |stream| stream.set_nonblocking(false));
|
||||
if served != Served::Answered {
|
||||
eprintln!("huginn: {served}");
|
||||
}
|
||||
}
|
||||
taken
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DeskDoor {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn profile() -> CultNetTransportProfile {
|
||||
create_tcp_framed_transport_profile(
|
||||
"huginn-desk",
|
||||
TcpFramedTransportProfileOptions { max_payload_bytes: Some(DESK_MAX_REQUEST_BYTES as u32), ..Default::default() },
|
||||
)
|
||||
}
|
||||
|
||||
/// A stream whose every read and write is bounded by what is left of one deadline: the socket's
|
||||
/// timeout is reset to the remainder before each call, so bytes arriving do not extend it.
|
||||
pub(crate) struct Deadlined<St> {
|
||||
inner: St,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl<St: AsFd> Deadlined<St> {
|
||||
pub(crate) fn new(inner: St, within: Duration) -> Self {
|
||||
Self { inner, deadline: Instant::now() + within }
|
||||
}
|
||||
|
||||
fn remaining(&self, which: Timeout) -> io::Result<()> {
|
||||
// Whole milliseconds: rustix rounds a sub-microsecond tail up into an out-of-range tv_usec.
|
||||
let left = Duration::from_millis(self.deadline.saturating_duration_since(Instant::now()).as_millis() as u64);
|
||||
if left.is_zero() {
|
||||
return Err(io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
set_socket_timeout(&self.inner, which, Some(left)).map_err(io::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl<St: Read + AsFd> Read for Deadlined<St> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.remaining(Timeout::Recv)?;
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<St: Write + AsFd> Write for Deadlined<St> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.remaining(Timeout::Send)?;
|
||||
self.inner.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection: the peer first, before anything is read, then one frame in, one frame out,
|
||||
/// all inside `DESK_CONNECTION_DEADLINE`. `ready` readies the stream for blocking I/O and runs
|
||||
/// only after the peer is known to be the configured one.
|
||||
pub fn serve_connection<St, S, I>(
|
||||
stream: St,
|
||||
peer_uid: u32,
|
||||
daemon: &mut Daemon<S, I>,
|
||||
registry: &CultNetSchemaRegistry,
|
||||
now: DateTime<Utc>,
|
||||
ready: impl FnOnce(&St) -> io::Result<()>,
|
||||
) -> Served
|
||||
where
|
||||
St: Read + Write + AsFd,
|
||||
S: MindStore,
|
||||
I: IndexSink<S>,
|
||||
{
|
||||
let uid = match rustix::net::sockopt::socket_peercred(&stream) {
|
||||
Ok(credentials) => credentials.uid.as_raw(),
|
||||
Err(error) => return Served::Dropped(format!("the peer's credentials could not be read: {error}")),
|
||||
};
|
||||
if uid != peer_uid {
|
||||
return Served::Refused { uid, expected: peer_uid };
|
||||
}
|
||||
if let Err(error) = ready(&stream) {
|
||||
return Served::Dropped(format!("the stream could not be readied: {error}"));
|
||||
}
|
||||
let mut transport = TcpFramedTransportConnection::new(Deadlined::new(stream, DESK_CONNECTION_DEADLINE), profile());
|
||||
let frame = match transport.receive() {
|
||||
Ok(frame) => frame,
|
||||
Err(error) => return Served::Dropped(format!("no whole frame arrived: {error:#}")),
|
||||
};
|
||||
let message = match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
|
||||
Ok(message) => message,
|
||||
Err(error) => return Served::Dropped(format!("a frame was not a CultNet message: {error:#}")),
|
||||
};
|
||||
let reply = answer_desk_message(daemon, registry, message, now);
|
||||
let bytes = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Served::Dropped(format!("the reply could not be encoded: {error:#}")),
|
||||
};
|
||||
match transport.send("schema", &bytes) {
|
||||
Ok(()) => Served::Answered,
|
||||
Err(error) => Served::Dropped(format!("the reply was not delivered: {error:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// One message in, one message out. Nothing here reaches a mind except through
|
||||
/// `Daemon::handle_desk`, and an envelope that does not decode never gets that far.
|
||||
pub fn answer_desk_message<S: MindStore, I: IndexSink<S>>(
|
||||
daemon: &mut Daemon<S, I>,
|
||||
registry: &CultNetSchemaRegistry,
|
||||
message: CultNetMessage,
|
||||
now: DateTime<Utc>,
|
||||
) -> CultNetMessage {
|
||||
let runtime_id = daemon.runtime_id();
|
||||
if let Some(field) = overlong_echo(&message) {
|
||||
return CultNetMessage::Error {
|
||||
error: format!("{DESK_SERVICE_ID} refuses a {field} over {MAX_ECHOED_FIELD_BYTES} bytes"),
|
||||
code: None,
|
||||
details: None,
|
||||
};
|
||||
}
|
||||
match &message {
|
||||
CultNetMessage::OperationRequest { message_id, operation, .. } => match decode_desk_request(&message) {
|
||||
Ok((message_id, request)) => {
|
||||
let operation = request.operation();
|
||||
let response = daemon.handle_desk(request, now);
|
||||
respond(&response, &message_id, operation, &runtime_id, DESK_MAX_RESPONSE_BYTES)
|
||||
}
|
||||
Err(failure) => encode_desk_failure(message_id, operation, &failure, &runtime_id),
|
||||
},
|
||||
CultNetMessage::SchemaCatalogRequest { .. } => match registry.create_catalog_response(&message) {
|
||||
Ok(reply) => bounded(reply, DESK_MAX_RESPONSE_BYTES, |size| CultNetMessage::Error {
|
||||
error: match size {
|
||||
Ok(bytes) => format!("{DESK_SERVICE_ID} refuses a catalog reply of {bytes} bytes over {DESK_MAX_RESPONSE_BYTES}"),
|
||||
Err(detail) => detail,
|
||||
},
|
||||
code: None,
|
||||
details: None,
|
||||
}),
|
||||
Err(error) => CultNetMessage::Error { error: format!("{error:#}"), code: None, details: None },
|
||||
},
|
||||
_ => CultNetMessage::Error {
|
||||
error: format!("{DESK_SERVICE_ID} answers cultnet.operation_request.v0 and cultnet.schema_catalog_request.v0"),
|
||||
code: None,
|
||||
details: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One answer as its message, or, past `limit` encoded, the refusal that names the size.
|
||||
pub(crate) fn respond(response: &DeskResponse, message_id: &str, operation: &str, runtime_id: &str, limit: usize) -> CultNetMessage {
|
||||
let not_encodable = |detail: String| {
|
||||
encode_desk_failure(message_id, operation, &OperationFailure { code: "response-not-encodable".into(), message: detail }, runtime_id)
|
||||
};
|
||||
let reply = match encode_desk_response(message_id, operation, response, runtime_id) {
|
||||
Ok(reply) => reply,
|
||||
Err(error) => return not_encodable(format!("{error:#}")),
|
||||
};
|
||||
bounded(reply, limit, |size| match size {
|
||||
Ok(bytes) => {
|
||||
// A read of one fork past the cap is that fork's refusal; every other reply is the generic one.
|
||||
let refusal = match response {
|
||||
DeskResponse::Fork { entry, .. } => DeskResponse::NotStored(fork_too_large_read(entry.card.fork_id.as_str())),
|
||||
_ => DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: bytes as u64, limit: limit as u64 }),
|
||||
};
|
||||
encode_desk_response(message_id, operation, &refusal, runtime_id).unwrap_or_else(|error| not_encodable(format!("{error:#}")))
|
||||
}
|
||||
Err(detail) => not_encodable(detail),
|
||||
})
|
||||
}
|
||||
|
||||
/// `reply` itself when it encodes to at most `limit` bytes. Otherwise what `refuse` makes of the
|
||||
/// encoded size, or of the encoding error. Every reply the door sends passes here.
|
||||
fn bounded(reply: CultNetMessage, limit: usize, refuse: impl FnOnce(Result<usize, String>) -> CultNetMessage) -> CultNetMessage {
|
||||
match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
|
||||
Ok(bytes) if bytes.len() <= limit => reply,
|
||||
Ok(bytes) => refuse(Ok(bytes.len())),
|
||||
Err(error) => refuse(Err(format!("{error:#}"))),
|
||||
}
|
||||
}
|
||||
1123
crates/huginn-daemon/src/desk_door_tests.rs
Normal file
1123
crates/huginn-daemon/src/desk_door_tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,12 +15,17 @@
|
|||
|
||||
pub mod bodies;
|
||||
pub mod daemon;
|
||||
pub mod desk_door;
|
||||
pub mod index;
|
||||
pub mod serve;
|
||||
pub mod sources;
|
||||
|
||||
#[cfg(test)]
|
||||
mod desk_door_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod semantic_tests;
|
||||
|
||||
pub use daemon::{Daemon, Found, Handled, Hits, IndexSink, Search, SearchTicket, runtime_id};
|
||||
pub use serve::{Options, ServeOptions, parse_options, run, startup};
|
||||
pub use desk_door::DeskDoor;
|
||||
pub use serve::{Options, ServeOptions, Started, parse_options, run, startup};
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ use anyhow::{Context, Result};
|
|||
use huginn_daemon::index::Backoff;
|
||||
use huginn_daemon::index::ollama::pool;
|
||||
use huginn_daemon::index::qdrant::QdrantIndex;
|
||||
use huginn_daemon::serve::{ServeOptions, parse_options, run, startup};
|
||||
use huginn_daemon::serve::{ServeOptions, Started, parse_options, run, startup};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let options = parse_options(std::env::args().skip(1))?;
|
||||
let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model);
|
||||
let index = QdrantIndex::new(&options.qdrant_url);
|
||||
let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) {
|
||||
let Started { mut daemon, mut hub, registry, mut desk } = match startup(&options, embedder, index, Backoff::default()) {
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
eprintln!("huginn-daemon refuses to serve {}: {error:#}", options.instance.0);
|
||||
|
|
@ -33,5 +33,8 @@ fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
eprintln!("huginn-daemon serving {} on {}", options.instance.0, hub.local_addr()?);
|
||||
run(&mut daemon, &mut hub, ®istry, &stopping, &ServeOptions::default())
|
||||
if let Some(door) = &desk {
|
||||
eprintln!("huginn-daemon serving the desk door on {}", door.path().display());
|
||||
}
|
||||
run(&mut daemon, &mut hub, ®istry, desk.as_mut(), &stopping, &ServeOptions::default())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ impl Harness {
|
|||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
let loop_stopping = Arc::clone(&stopping);
|
||||
let options = ServeOptions::default();
|
||||
let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, ®istry, &loop_stopping, &options));
|
||||
let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, ®istry, None, &loop_stopping, &options));
|
||||
Self { endpoint, stopping, serving: Some(serving), embedder, index, _root: root }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ use crate::bodies::{
|
|||
DEFERRED_BUDGET_BYTES, DEFERRED_CHUNK_BYTES, DeferredBodies,
|
||||
};
|
||||
use crate::daemon::{Daemon, Handled, IndexSink, Search, SearchTicket, runtime_id};
|
||||
use crate::desk_door::{DeskDoor, bind_desk};
|
||||
use huginn_mind::envelope::{OperationFailure, decode_request, encode_failure, encode_response};
|
||||
use huginn_mind::MAX_DEFERRED_BODY_BYTES;
|
||||
use crate::index::{Backoff, Embedder, VectorIndex, WorkerSink};
|
||||
|
|
@ -118,12 +119,22 @@ pub struct Options {
|
|||
pub embedding_model: String,
|
||||
/// The forge a persona's activity read goes to; absent, that read is refused.
|
||||
pub forge_url: Option<String>,
|
||||
/// The desk door: the unix socket Bifrost's receiver answers on, and the one uid that may
|
||||
/// speak on it. Absent, the daemon serves the RUDP door alone.
|
||||
pub desk: Option<DeskOptions>,
|
||||
}
|
||||
|
||||
/// Where the desk door listens and who may speak there.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeskOptions {
|
||||
pub socket: PathBuf,
|
||||
pub peer_uid: u32,
|
||||
}
|
||||
|
||||
/// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`,
|
||||
/// `--ollama-url` and `--embedding-model`, each required, each once, except
|
||||
/// `--ollama-url`, which repeats for each GPU host, and the optional
|
||||
/// `--reserve-ollama-url`, which repeats for each CPU host, and the optional `--forge-url`, once. The URLs are plain
|
||||
/// `--reserve-ollama-url`, which repeats for each CPU host, and the optional `--forge-url`, once, and the optional pair `--desk-socket` (an absolute path) and `--desk-peer-uid` (a uid other than 0), both or neither, each once. The URLs are plain
|
||||
/// `http://`: this build carries no TLS.
|
||||
pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
|
||||
let mut args = args;
|
||||
|
|
@ -132,7 +143,7 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
|
|||
while let Some(name) = args.next() {
|
||||
let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?;
|
||||
ensure!(
|
||||
matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model" | "forge-url"),
|
||||
matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model" | "forge-url" | "desk-socket" | "desk-peer-uid"),
|
||||
"unsupported Huginn option --{name}"
|
||||
);
|
||||
let value = args.next().with_context(|| format!("missing value for --{name}"))?;
|
||||
|
|
@ -168,6 +179,17 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
|
|||
reserve_ollama_urls,
|
||||
embedding_model,
|
||||
forge_url: values.contains_key("forge-url").then(|| url("forge-url")).transpose()?,
|
||||
desk: match (values.get("desk-socket"), values.get("desk-peer-uid")) {
|
||||
(None, None) => None,
|
||||
(Some(socket), Some(uid)) => {
|
||||
let socket = PathBuf::from(socket);
|
||||
ensure!(socket.is_absolute(), "--desk-socket must be an absolute path");
|
||||
let peer_uid: u32 = uid.parse().with_context(|| format!("--desk-peer-uid must be a uid, got {uid:?}"))?;
|
||||
ensure!(peer_uid != 0, "--desk-peer-uid must not be 0");
|
||||
Some(DeskOptions { socket, peer_uid })
|
||||
}
|
||||
_ => anyhow::bail!("--desk-socket and --desk-peer-uid are given together or not at all"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -177,18 +199,14 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
|
|||
/// of the refusal and nothing reaches the vector store for a process that
|
||||
/// never listens. The worker's first act is to reconcile the collection
|
||||
/// against every indexable document the mind holds now.
|
||||
pub fn startup<E, V>(
|
||||
options: &Options,
|
||||
embedder: E,
|
||||
index: V,
|
||||
backoff: Backoff,
|
||||
) -> Result<(Daemon<OwnedRedbMessagePackBackingStore, WorkerSink>, CultNetRudpServerHub, CultNetSchemaRegistry)>
|
||||
pub fn startup<E, V>(options: &Options, embedder: E, index: V, backoff: Backoff) -> Result<Started>
|
||||
where
|
||||
E: Embedder + Send + 'static,
|
||||
V: VectorIndex + crate::sources::SourceIndex + Send + 'static,
|
||||
{
|
||||
let mind = Mind::open(&options.state_root, &options.instance)?;
|
||||
let hub = bind(options.bind, &runtime_id(&options.instance))?;
|
||||
let desk = options.desk.as_ref().map(|desk| bind_desk(&desk.socket, desk.peer_uid)).transpose()?;
|
||||
let startup_entries = mind.index_entries(None)?;
|
||||
let sink = WorkerSink::spawn(embedder, index, &options.instance, mind.genesis_receipt_id()?, startup_entries, backoff);
|
||||
let daemon = Daemon::new(mind, sink);
|
||||
|
|
@ -196,7 +214,16 @@ where
|
|||
Some(url) => daemon.with_forge(crate::sources::forge::HttpForge::new(url)),
|
||||
None => daemon,
|
||||
};
|
||||
Ok((daemon, hub, schema_registry()?))
|
||||
Ok(Started { daemon, hub, registry: schema_registry()?, desk })
|
||||
}
|
||||
|
||||
/// What `startup` hands the loop: the daemon over the opened mind, the bound RUDP hub, the
|
||||
/// catalog the RUDP door advertises, and the desk door when one was asked for.
|
||||
pub struct Started {
|
||||
pub daemon: Daemon<OwnedRedbMessagePackBackingStore, WorkerSink>,
|
||||
pub hub: CultNetRudpServerHub,
|
||||
pub registry: CultNetSchemaRegistry,
|
||||
pub desk: Option<DeskDoor>,
|
||||
}
|
||||
|
||||
/// The bytes one reliable packet carries, and the packets one session may hold
|
||||
|
|
@ -255,7 +282,7 @@ pub const MAX_ECHOED_FIELD_BYTES: usize = 256;
|
|||
|
||||
/// The first field of a request that a reply would echo and that is over
|
||||
/// `MAX_ECHOED_FIELD_BYTES`.
|
||||
fn overlong_echo(message: &CultNetMessage) -> Option<&'static str> {
|
||||
pub(crate) fn overlong_echo(message: &CultNetMessage) -> Option<&'static str> {
|
||||
let echoed: Vec<(&'static str, &str)> = match message {
|
||||
CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, .. } => {
|
||||
vec![
|
||||
|
|
@ -479,6 +506,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
|
|||
daemon: &mut Daemon<S, I>,
|
||||
hub: &mut CultNetRudpServerHub,
|
||||
registry: &CultNetSchemaRegistry,
|
||||
mut desk: Option<&mut DeskDoor>,
|
||||
stopping: &AtomicBool,
|
||||
options: &ServeOptions,
|
||||
) -> Result<()> {
|
||||
|
|
@ -523,6 +551,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
|
|||
}
|
||||
served += 1;
|
||||
}
|
||||
let desk_served = desk.as_deref_mut().map_or(0, |door| door.serve_ready(daemon, Utc::now()));
|
||||
let finished = collect_searches(daemon, &waiting);
|
||||
let answered = !finished.is_empty();
|
||||
for (ticket, found) in finished {
|
||||
|
|
@ -531,7 +560,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
|
|||
let reply = respond(&response, &asked.message_id, asked.operation, &daemon.runtime_id(), &mut bodies, Utc::now());
|
||||
send(hub, &asked.session, &reply);
|
||||
}
|
||||
if served == 0 && !answered {
|
||||
if served == 0 && desk_served == 0 && !answered {
|
||||
std::thread::sleep(options.idle_sleep);
|
||||
}
|
||||
}
|
||||
|
|
@ -756,7 +785,7 @@ mod tests {
|
|||
|
||||
let loop_stopping = Arc::clone(&stopping);
|
||||
let serving = std::thread::spawn(move || {
|
||||
let result = run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default());
|
||||
let result = run(&mut daemon, &mut hub, ®istry, None, &loop_stopping, &ServeOptions::default());
|
||||
(result, daemon)
|
||||
});
|
||||
|
||||
|
|
@ -863,7 +892,7 @@ mod tests {
|
|||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
let loop_stopping = Arc::clone(&stopping);
|
||||
let serving = std::thread::spawn(move || {
|
||||
let result = run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default());
|
||||
let result = run(&mut daemon, &mut hub, ®istry, None, &loop_stopping, &ServeOptions::default());
|
||||
(result, daemon)
|
||||
});
|
||||
|
||||
|
|
@ -1104,7 +1133,7 @@ mod tests {
|
|||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
let loop_stopping = Arc::clone(&stopping);
|
||||
let serving = std::thread::spawn(move || {
|
||||
let result = run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default());
|
||||
let result = run(&mut daemon, &mut hub, ®istry, None, &loop_stopping, &ServeOptions::default());
|
||||
(result, daemon)
|
||||
});
|
||||
|
||||
|
|
@ -1541,6 +1570,7 @@ mod tests {
|
|||
reserve_ollama_urls: Vec::new(),
|
||||
embedding_model: "a-model".into(),
|
||||
forge_url: None,
|
||||
desk: None,
|
||||
};
|
||||
let started = |options: &Options| {
|
||||
let (embedder, index) = crate::index::fakes::pair();
|
||||
|
|
@ -1556,8 +1586,8 @@ mod tests {
|
|||
let error = format!("{:#}", started(&options).err().expect("a held port refuses"));
|
||||
assert!(error.contains("binding"), "{error}");
|
||||
drop(occupied);
|
||||
let (_daemon, hub, _registry) = started(&options).expect("both gates open");
|
||||
assert_eq!(hub.local_addr().unwrap(), port);
|
||||
let started = started(&options).expect("both gates open");
|
||||
assert_eq!(started.hub.local_addr().unwrap(), port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1579,6 +1609,7 @@ mod tests {
|
|||
reserve_ollama_urls: Vec::new(),
|
||||
embedding_model: "qwen3-embedding:0.6b".into(),
|
||||
forge_url: None,
|
||||
desk: None,
|
||||
}
|
||||
);
|
||||
// Each of the six is required: there is no index-less mode.
|
||||
|
|
|
|||
913
crates/huginn-daemon/tests/desk_door.rs
Normal file
913
crates/huginn-daemon/tests/desk_door.rs
Normal file
|
|
@ -0,0 +1,913 @@
|
|||
//! The desk door through the built binary: a real `huginn-daemon` process, its two sockets and its
|
||||
//! mind on disk. What the unit and pipeline smokes cannot show: the flags reach the door, the
|
||||
//! door answers beside the RUDP door in one process, a stopped daemon takes its socket with it,
|
||||
//! a leftover socket is replaced, a retry after a restart replays the stored answer, and a peer
|
||||
//! that is not the configured uid is refused (logged by name) with the mind untouched.
|
||||
//!
|
||||
//! Needs a non-root uid: the door refuses uid 0.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::UdpSocket;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use cultnet_rs::{
|
||||
CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, CultNetWireContract,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, encode_frame,
|
||||
};
|
||||
use huginn_mind::desk_wire::{DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, RequestSchema};
|
||||
use huginn_mind::envelope::{decode_desk_response, decode_response, encode_desk_request, encode_request};
|
||||
use huginn_mind::eureka_pipeline::{
|
||||
Date, DeskAuthority, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineQuestion, PipelineStewardship,
|
||||
QuestionOption, Sha, Short, Title,
|
||||
};
|
||||
use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse};
|
||||
use huginn_mind::{Faculty, Mind, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance, options_hash};
|
||||
|
||||
const INSTANCE: &str = "yggdrasil";
|
||||
const FORK: &str = "eureka-state:question:Q1";
|
||||
const R1: &str = "00000000-0000-4000-8000-000000000001";
|
||||
const R2: &str = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
/// These tests run one at a time: each forks daemons and times them, and a fork made while another
|
||||
/// test holds a mind open lends that child the mind's lock until it execs.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn my_uid() -> u32 {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::metadata(dir.path()).unwrap().uid()
|
||||
}
|
||||
|
||||
fn short(value: &str) -> Short {
|
||||
Short(value.into())
|
||||
}
|
||||
|
||||
/// A mind holding the campaign and one answerable question; returns the question's `options_hash`.
|
||||
fn seed(root: &Path) -> String {
|
||||
let mut mind = Mind::open(root, &INSTANCE.into()).unwrap();
|
||||
let question = PipelineQuestion {
|
||||
campaign: "eureka-state".into(),
|
||||
label: "Q1".into(),
|
||||
title: Title("Who owns the state?".into()),
|
||||
question: "Who owns the state?".into(),
|
||||
options: vec![
|
||||
QuestionOption { label: "A".into(), text: "the organ".into() },
|
||||
QuestionOption { label: "B".into(), text: "the client".into() },
|
||||
],
|
||||
recommended: "A".into(),
|
||||
depends: vec![],
|
||||
raised_in: None,
|
||||
asked_on: Date("2026-09-16".into()),
|
||||
};
|
||||
let hash = options_hash(&question).0;
|
||||
let repo = OrgRepo("GameCult/Epiphany".into());
|
||||
let documents = vec![
|
||||
PipelineDocument::Instance(PipelineInstance {
|
||||
instance: INSTANCE.into(),
|
||||
display_name: short("yggdrasil mind"),
|
||||
created_at: Date("2026-09-16".into()),
|
||||
host: short(INSTANCE),
|
||||
}),
|
||||
PipelineDocument::Stewardship(PipelineStewardship {
|
||||
instance: INSTANCE.into(),
|
||||
repo: repo.clone(),
|
||||
sequence: 1,
|
||||
assigned_on: Date("2026-09-16".into()),
|
||||
note: "assigned".into(),
|
||||
names: vec![],
|
||||
}),
|
||||
PipelineDocument::Campaign(PipelineCampaign {
|
||||
slug: "eureka-state".into(),
|
||||
title: Title("Eureka pipeline state".into()),
|
||||
repos: vec![repo],
|
||||
working_branch: short("codex/eureka-pipeline-state"),
|
||||
target_doc: DocRef { path: short("notes/target.md"), start_line: 1, end_line: 9, commit: Sha("5f98228d".into()) },
|
||||
}),
|
||||
PipelineDocument::Question(question),
|
||||
];
|
||||
let provenance = PipelineProvenance {
|
||||
faculty: Faculty::Hands,
|
||||
agent: short("claude"),
|
||||
session: short("session-1"),
|
||||
tool: short("admit"),
|
||||
imported_from: None,
|
||||
};
|
||||
let batch = PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents };
|
||||
let outcome = mind.admit(batch, Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap());
|
||||
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
||||
hash
|
||||
}
|
||||
|
||||
/// A daemon process over `root`, its RUDP door on a free local port, its desk door at `desk`.
|
||||
struct Daemon {
|
||||
child: Child,
|
||||
rudp: String,
|
||||
log: PathBuf,
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn spawn(root: &Path, desk: &Path, peer_uid: u32, log: &Path) -> Daemon {
|
||||
let port = free_port();
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_huginn-daemon"))
|
||||
.args(["--state-root", root.to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{port}")])
|
||||
.args(["--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "a-model"])
|
||||
.args(["--desk-socket", desk.to_str().unwrap(), "--desk-peer-uid", &peer_uid.to_string()])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(std::fs::OpenOptions::new().create(true).append(true).open(log).unwrap())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut daemon = Daemon { child, rudp: format!("rudp://127.0.0.1:{port}"), log: log.to_path_buf() };
|
||||
let started = Instant::now();
|
||||
// Listening, not merely present: a killed predecessor leaves a socket nobody answers on.
|
||||
while UnixStream::connect(desk).is_err() {
|
||||
assert!(daemon.child.try_wait().unwrap().is_none(), "the daemon exited early:\n{}", daemon.log_text());
|
||||
assert!(started.elapsed() < Duration::from_secs(30), "the desk socket never appeared:\n{}", daemon.log_text());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
daemon
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
fn log_text(&self) -> String {
|
||||
std::fs::read_to_string(&self.log).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn signal(&mut self, name: &str) {
|
||||
let status = Command::new("kill").args([&format!("-{name}"), &self.child.id().to_string()]).status().unwrap();
|
||||
assert!(status.success());
|
||||
let started = Instant::now();
|
||||
while self.child.try_wait().unwrap().is_none() {
|
||||
assert!(started.elapsed() < Duration::from_secs(20), "the daemon did not stop on {name}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn documents(&self) -> u32 {
|
||||
let mut client = CultMesh::create_rudp_client_for_endpoint(
|
||||
"desk-e2e".to_string(),
|
||||
CULTNET_OPERATION_CONNECTION_ID,
|
||||
&self.rudp,
|
||||
CultMeshRudpSocketOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
client.connect(Vec::new()).unwrap();
|
||||
for _ in 0..2000 {
|
||||
let _ = client.receive_once();
|
||||
if client.connected() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert!(client.connected(), "the RUDP door never accepted:\n{}", self.log_text());
|
||||
client.send_schema_message(&encode_request("w-1", &HuginnMindRequest::Whoami, None).unwrap()).unwrap();
|
||||
for _ in 0..2000 {
|
||||
client.poll_resends().unwrap();
|
||||
if let Some(reply) = client.receive_schema_message_once().unwrap() {
|
||||
return match decode_response(&reply).unwrap().1.unwrap() {
|
||||
HuginnMindResponse::Whoami(status) => status.documents,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
panic!("whoami was not answered");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
|
||||
DeskRequest::Answer {
|
||||
mind: INSTANCE.into(),
|
||||
subject: short(who),
|
||||
authority: DeskAuthority::Operator,
|
||||
deciders: vec![short(who)],
|
||||
request: ForkAnswerRequest {
|
||||
schema_version: RequestSchema::V0,
|
||||
fork_id: FORK.into(),
|
||||
options_hash: hash.into(),
|
||||
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
|
||||
retracted: false,
|
||||
retracts: None,
|
||||
answered_at: "2026-09-16T11:00:00Z".into(),
|
||||
request_id: request_id.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest {
|
||||
DeskRequest::Forks {
|
||||
mind: INSTANCE.into(),
|
||||
subject: short(who),
|
||||
deciders: deciders.iter().map(|d| short(d)).collect(),
|
||||
status: None,
|
||||
cursor: None,
|
||||
limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn framed(message: &CultNetMessage) -> Vec<u8> {
|
||||
encode_frame(&encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn desk_bytes(id: &str, request: &DeskRequest) -> Vec<u8> {
|
||||
framed(&encode_desk_request(id, request, None).unwrap())
|
||||
}
|
||||
|
||||
fn read_reply(stream: &mut impl Read) -> Option<CultNetMessage> {
|
||||
let mut header = [0_u8; 4];
|
||||
stream.read_exact(&mut header).ok()?;
|
||||
let mut payload = vec![0_u8; u32::from_be_bytes(header) as usize];
|
||||
stream.read_exact(&mut payload).ok()?;
|
||||
Some(decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0).unwrap())
|
||||
}
|
||||
|
||||
fn exchange(path: &Path, bytes: &[u8]) -> Option<CultNetMessage> {
|
||||
let mut stream = UnixStream::connect(path).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let _ = stream.write_all(bytes);
|
||||
read_reply(&mut stream)
|
||||
}
|
||||
|
||||
fn ask(path: &Path, id: &str, request: &DeskRequest) -> DeskResponse {
|
||||
let reply = exchange(path, &desk_bytes(id, request)).expect("the door replied");
|
||||
decode_desk_response(&reply).unwrap().1.unwrap_or_else(|failure| panic!("{failure:?}"))
|
||||
}
|
||||
|
||||
fn counted_id(response: DeskResponse) -> String {
|
||||
match response {
|
||||
DeskResponse::Answered { answer, .. } => {
|
||||
assert_eq!(answer.outcome, "Counted", "{answer:?}");
|
||||
answer.answer_id
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_request() -> CultNetMessage {
|
||||
CultNetMessage::SchemaCatalogRequest { message_id: "c-1".into(), include_schema_json: Some(false), schema_ids: None, kinds: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_serves_both_doors_and_survives_restart() {
|
||||
let _serial = serial();
|
||||
assert_ne!(my_uid(), 0, "the door refuses uid 0, so this test cannot run as root");
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let hash = seed(root.path());
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
|
||||
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
let seeded = daemon.documents();
|
||||
// The catalog is the two desk schemas.
|
||||
let catalog = format!("{:?}", exchange(&desk, &framed(&catalog_request())).expect("a catalog"));
|
||||
assert!(catalog.contains("huginn.desk_request.v1") && catalog.contains("huginn.desk_response.v1"), "{catalog}");
|
||||
assert!(!catalog.contains("huginn.mind_request.v1"), "{catalog}");
|
||||
// The reads answer, store nothing, and carry the whole record to a subject the deciders do not name.
|
||||
let (list, listed_at) = match ask(&desk, "m-r1", &forks_request("x", &["x"])) {
|
||||
DeskResponse::Forks { list, as_of } => (list, as_of),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.stamp.clone()), (1, 0, 1, format!("s{listed_at}")));
|
||||
assert_eq!(list.forks[0].card.options_hash, hash);
|
||||
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })
|
||||
else {
|
||||
panic!("a read of the seeded fork")
|
||||
};
|
||||
assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at));
|
||||
assert_eq!(daemon.documents(), seeded);
|
||||
// `authority` is no field of a read: the payload is refused whole and nothing is answered or stored.
|
||||
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })] {
|
||||
let mut value = serde_json::to_value(&request).unwrap();
|
||||
let variant = if operation == "forks" { "Forks" } else { "Fork" };
|
||||
value[variant]["authority"] = serde_json::json!("Operator");
|
||||
let message = CultNetMessage::OperationRequest {
|
||||
message_id: "m-ra".into(),
|
||||
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
|
||||
operation: operation.into(),
|
||||
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
|
||||
payload_encoding: "messagepack-base64".into(),
|
||||
payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
};
|
||||
let reply = exchange(&desk, &framed(&message)).expect("the door replied");
|
||||
let failure = decode_desk_response(&reply).unwrap().1.unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request", "{operation}");
|
||||
}
|
||||
assert_eq!(daemon.documents(), seeded);
|
||||
// An answer, then a stale one.
|
||||
let first = answer("x", &hash, R1);
|
||||
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1");
|
||||
let DeskResponse::Answered { answer: stale, counted_answer_id, .. } = ask(&desk, "m-2", &answer("x", &"0".repeat(64), R2)) else {
|
||||
panic!("a stale answer is stored")
|
||||
};
|
||||
assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1")));
|
||||
let stored = daemon.documents();
|
||||
assert_eq!(stored, seeded + 2);
|
||||
// A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code.
|
||||
let DeskResponse::Fork { entry, .. } =
|
||||
ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() })
|
||||
else {
|
||||
panic!("a read")
|
||||
};
|
||||
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_str(), answer.code)).collect();
|
||||
assert_eq!(seen, [("n1", "x", None), ("n2", "x", Some(ForkAnswerCode::StaleFork))]);
|
||||
assert_eq!((entry.record.counted_answer_id.as_deref(), entry.card.deciders.as_slice()), (Some("n1"), ["y".to_string()].as_slice()));
|
||||
assert_eq!(daemon.documents(), stored);
|
||||
|
||||
// A stop takes the socket with it; the stored answers are the retry's memory.
|
||||
daemon.signal("TERM");
|
||||
assert!(!desk.exists(), "a stopped daemon unlinks its socket");
|
||||
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
assert_eq!(daemon.documents(), stored);
|
||||
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1", "the same request id returns the stored answer");
|
||||
assert_eq!(daemon.documents(), stored, "the replay stored nothing");
|
||||
|
||||
// A killed daemon leaves its socket; the next one replaces it.
|
||||
daemon.signal("KILL");
|
||||
assert!(desk.exists(), "a killed daemon leaves its socket behind");
|
||||
let daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1");
|
||||
assert_eq!(daemon.documents(), stored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_refuses_another_uid() {
|
||||
let _serial = serial();
|
||||
let mine = my_uid();
|
||||
assert_ne!(mine, 0);
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let hash = seed(root.path());
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
let mut daemon = spawn(root.path(), &desk, mine + 1, &log);
|
||||
let seeded = daemon.documents();
|
||||
assert!(exchange(&desk, &desk_bytes("m-1", &answer("x", &hash, R1))).is_none(), "a refused peer reads EOF");
|
||||
assert!(exchange(&desk, &framed(&catalog_request())).is_none(), "not even the catalog");
|
||||
assert_eq!(daemon.documents(), seeded);
|
||||
daemon.signal("TERM");
|
||||
let wanted = format!("desk door refused a peer with uid {mine} (expects {})", mine + 1);
|
||||
assert!(daemon.log_text().contains(&wanted), "the log names both uids:\n{}", daemon.log_text());
|
||||
// Read-only reopen: the mind is as it was.
|
||||
let reopened = Mind::open(root.path(), &INSTANCE.into()).unwrap();
|
||||
assert_eq!(reopened.status(huginn_mind::wire::IndexStatus::Current).documents, seeded);
|
||||
}
|
||||
|
||||
/// Soul's probe, from the built binary: a client that is uid 0 inside its own user namespace is
|
||||
/// the real uid to the kernel, so the door sees the real uid and namespace root cannot pass as root.
|
||||
#[test]
|
||||
fn namespace_root_is_seen_as_the_real_uid() {
|
||||
let _serial = serial();
|
||||
let probe = Command::new("unshare").args(["-r", "true"]).status();
|
||||
if !probe.is_ok_and(|status| status.success()) || Command::new("socat").arg("-V").stdout(Stdio::null()).status().is_err() {
|
||||
eprintln!("skipped: needs `unshare -r` (user namespaces) and socat");
|
||||
return;
|
||||
}
|
||||
let mine = my_uid();
|
||||
assert_ne!(mine, 0);
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
seed(root.path());
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
let frame = run.path().join("catalog.frame");
|
||||
std::fs::write(&frame, framed(&catalog_request())).unwrap();
|
||||
let through_namespace = || {
|
||||
Command::new("unshare")
|
||||
.args(["-r", "socat", "-t", "2", "-", &format!("UNIX-CONNECT:{}", desk.display())])
|
||||
.stdin(std::fs::File::open(&frame).unwrap())
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout
|
||||
};
|
||||
|
||||
let mut allowed = spawn(root.path(), &desk, mine, &log);
|
||||
let reply = through_namespace();
|
||||
assert!(!reply.is_empty(), "the real uid is the configured one, so namespace root is answered:\n{}", allowed.log_text());
|
||||
allowed.signal("TERM");
|
||||
|
||||
let mut other = spawn(root.path(), &desk, mine + 1, &log);
|
||||
assert!(through_namespace().is_empty(), "a door configured for another uid answers nobody");
|
||||
other.signal("TERM");
|
||||
let text = other.log_text();
|
||||
assert!(text.contains(&format!("desk door refused a peer with uid {mine} (expects {})", mine + 1)), "{text}");
|
||||
assert!(!text.contains("refused a peer with uid 0"), "the door never sees namespace root as 0:\n{text}");
|
||||
}
|
||||
|
||||
// ---- the loop's bounds, from the built binary and from run() in process ----
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, String, PathBuf, PathBuf) {
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let hash = seed(root.path());
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
(root, run, hash, desk, log)
|
||||
}
|
||||
|
||||
/// A peer that sends a valid frame one byte per 300 ms: inside any per-read timeout, so only a
|
||||
/// per-connection deadline cuts it off. Returns when the door has closed on it, with the bytes sent.
|
||||
fn drip(desk: &Path, hash: &str) -> std::thread::JoinHandle<(usize, Duration)> {
|
||||
let (desk, bytes) = (desk.to_path_buf(), desk_bytes("m-1", &answer("x", hash, R1)));
|
||||
std::thread::spawn(move || {
|
||||
let mut stream = UnixStream::connect(&desk).unwrap();
|
||||
let started = Instant::now();
|
||||
let mut sent = 0;
|
||||
for byte in bytes.iter().take(60) {
|
||||
if stream.write_all(&[*byte]).is_err() {
|
||||
break;
|
||||
}
|
||||
sent += 1;
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
(sent, started.elapsed())
|
||||
})
|
||||
}
|
||||
|
||||
/// The bound the door states for one connection is one second, so a dripping peer neither delays
|
||||
/// another door's answer nor a stop beyond it (the sum is checked with the margin a loaded host needs).
|
||||
#[test]
|
||||
fn a_dripping_peer_does_not_delay_the_rudp_door() {
|
||||
let _serial = serial();
|
||||
let (root, _run, hash, desk, log) = setup();
|
||||
let daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
let dripper = drip(&desk, &hash);
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
let asked = Instant::now();
|
||||
assert!(daemon.documents() > 0);
|
||||
assert!(asked.elapsed() < Duration::from_millis(2500), "the RUDP whoami waited {:?} behind a drip", asked.elapsed());
|
||||
let (sent, lasted) = dripper.join().unwrap();
|
||||
assert!(lasted < Duration::from_millis(1800), "the drip was held for {lasted:?} ({sent} bytes), not cut at one second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dripping_peer_does_not_delay_a_stop() {
|
||||
let _serial = serial();
|
||||
let (root, _run, hash, desk, log) = setup();
|
||||
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
let dripper = drip(&desk, &hash);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let signalled = Instant::now();
|
||||
let status = Command::new("kill").args(["-TERM", &daemon.child.id().to_string()]).status().unwrap();
|
||||
assert!(status.success());
|
||||
while daemon.child.try_wait().unwrap().is_none() {
|
||||
assert!(signalled.elapsed() < Duration::from_millis(2500), "the daemon was still held by a drip {:?} after SIGTERM", signalled.elapsed());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
dripper.join().unwrap();
|
||||
}
|
||||
|
||||
fn ticks(pid: u32) -> u64 {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
|
||||
let rest = &stat[stat.rfind(')').unwrap() + 2..];
|
||||
let fields: Vec<&str> = rest.split(' ').collect();
|
||||
fields[11].parse::<u64>().unwrap() + fields[12].parse::<u64>().unwrap()
|
||||
}
|
||||
|
||||
/// An idle daemon sleeps between passes: a loop that spins when nothing waits burns the core.
|
||||
#[test]
|
||||
fn an_idle_daemon_does_not_spin() {
|
||||
let _serial = serial();
|
||||
let (root, _run, _hash, desk, log) = setup();
|
||||
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
let before = ticks(daemon.child.id());
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
let burned = ticks(daemon.child.id()) - before;
|
||||
// 100 ticks a second: a spinning loop burns about 200 in two seconds, an idle one a handful.
|
||||
assert!(burned < 30, "an idle daemon burned {burned} ticks in 2 s");
|
||||
daemon.signal("TERM");
|
||||
}
|
||||
|
||||
/// WouldBlock on an idle listener is not an error and is not logged; a real accept error is.
|
||||
#[test]
|
||||
fn an_idle_door_logs_no_accept_error_and_a_real_one_is_logged() {
|
||||
let _serial = serial();
|
||||
let (root, _run, _hash, desk, log) = setup();
|
||||
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert!(!daemon.log_text().contains("could not accept"), "an idle door logged:\n{}", daemon.log_text());
|
||||
// An accept error: the daemon's soft fd limit is 3, stdin, stdout and stderr, so no
|
||||
// descriptor number is free whatever else it holds open; then a peer connects.
|
||||
let pid = daemon.child.id();
|
||||
let limit = |soft: usize| {
|
||||
let status = Command::new("prlimit").args([&format!("--pid={pid}"), &format!("--nofile={soft}:4096")]).status();
|
||||
status.is_ok_and(|status| status.success())
|
||||
};
|
||||
if !limit(3) {
|
||||
eprintln!("skipped: needs prlimit");
|
||||
return;
|
||||
}
|
||||
let _peer = UnixStream::connect(&desk).unwrap();
|
||||
let started = Instant::now();
|
||||
while !daemon.log_text().contains("could not accept") && started.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
limit(1024);
|
||||
assert!(daemon.log_text().contains("could not accept"), "an accept error went unlogged:\n{}", daemon.log_text());
|
||||
daemon.signal("TERM");
|
||||
}
|
||||
|
||||
/// A pass that served desk connections is followed at once by the next pass, not by the idle
|
||||
/// sleep: eight queued connections are answered in two passes of four, with no sleep between.
|
||||
#[test]
|
||||
fn a_busy_pass_is_not_followed_by_the_idle_sleep() {
|
||||
let _serial = serial();
|
||||
use huginn_daemon::index::Backoff;
|
||||
use huginn_daemon::index::ollama::pool;
|
||||
use huginn_daemon::index::qdrant::QdrantIndex;
|
||||
use huginn_daemon::serve::{ServeOptions, Started, parse_options, run, startup};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let (root, _run, _hash, desk, _log) = setup();
|
||||
let args = [
|
||||
"--state-root", root.path().to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{}", free_port()),
|
||||
"--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "a-model",
|
||||
"--desk-socket", desk.to_str().unwrap(), "--desk-peer-uid", &my_uid().to_string(),
|
||||
];
|
||||
let options = parse_options(args.iter().map(|arg| arg.to_string())).unwrap();
|
||||
let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model);
|
||||
let Started { mut daemon, mut hub, registry, desk: mut door } =
|
||||
startup(&options, embedder, QdrantIndex::new(&options.qdrant_url), Backoff::default()).unwrap();
|
||||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
let loop_stopping = Arc::clone(&stopping);
|
||||
let serving = std::thread::spawn(move || {
|
||||
let serve = ServeOptions { idle_sleep: Duration::from_millis(600), ..ServeOptions::default() };
|
||||
run(&mut daemon, &mut hub, ®istry, door.as_mut(), &loop_stopping, &serve)
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let frame = framed(&catalog_request());
|
||||
let mut peers: Vec<UnixStream> = (0..8)
|
||||
.map(|_| {
|
||||
let mut peer = UnixStream::connect(&desk).unwrap();
|
||||
peer.write_all(&frame).unwrap();
|
||||
peer.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
peer
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let answered: Vec<Duration> = peers
|
||||
.iter_mut()
|
||||
.map(|peer| {
|
||||
assert!(read_reply(peer).is_some());
|
||||
started.elapsed()
|
||||
})
|
||||
.collect();
|
||||
stopping.store(true, Ordering::SeqCst);
|
||||
serving.join().unwrap().unwrap();
|
||||
let gap = answered[4] - answered[3];
|
||||
assert!(gap < Duration::from_millis(300), "the second batch waited {gap:?}: the loop slept after a busy pass ({answered:?})");
|
||||
}
|
||||
|
||||
// ---- fork_too_large over the real socket ----
|
||||
|
||||
fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest {
|
||||
let mut value = serde_json::to_value(forks_request(who, &[who])).unwrap();
|
||||
value["Forks"]["cursor"] = cursor.map_or(serde_json::Value::Null, |text| serde_json::json!(text));
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
|
||||
fn fork_read(who: &str, fork_id: &str) -> DeskRequest {
|
||||
DeskRequest::Fork { mind: INSTANCE.into(), subject: short(who), deciders: vec![short(who)], fork_id: fork_id.into() }
|
||||
}
|
||||
|
||||
/// The wire spelling of a not-stored refusal's code, and its text.
|
||||
fn refusal_of(response: &DeskResponse) -> Option<(String, String)> {
|
||||
let DeskResponse::NotStored(refusal) = response else { return None };
|
||||
Some((serde_json::to_value(refusal.code).unwrap().as_str().unwrap().to_string(), refusal.error.clone()))
|
||||
}
|
||||
|
||||
/// Three forks, the middle one's record grown over the socket until its card alone is past the page
|
||||
/// budget. The list names it and its order (`fork_too_large`), the cursor `c<order>` walks on to the
|
||||
/// older fork and ends at order 0, a READ of it never carries an order or cursor (an entry while it fits the
|
||||
/// response bound, the fork-only `fork_too_large` past it), and `authority` on a read is still
|
||||
/// refused.
|
||||
#[test]
|
||||
fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
|
||||
let _serial = serial();
|
||||
assert_ne!(my_uid(), 0, "the door refuses uid 0, so this test cannot run as root");
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let hash = seed(root.path());
|
||||
{
|
||||
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
|
||||
let documents = ["Q2", "Q3"]
|
||||
.into_iter()
|
||||
.map(|label| PipelineDocument::Question(PipelineQuestion {
|
||||
campaign: "eureka-state".into(),
|
||||
label: label.into(),
|
||||
title: Title("Who owns the state?".into()),
|
||||
question: "Who owns the state?".into(),
|
||||
options: vec![
|
||||
QuestionOption { label: "A".into(), text: "the organ".into() },
|
||||
QuestionOption { label: "B".into(), text: "the client".into() },
|
||||
],
|
||||
recommended: "A".into(),
|
||||
depends: vec![],
|
||||
raised_in: None,
|
||||
asked_on: Date("2026-09-16".into()),
|
||||
}))
|
||||
.collect();
|
||||
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
|
||||
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
|
||||
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
||||
}
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
let _daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
let (older, big, newer) = (FORK, "eureka-state:question:Q2", "eureka-state:question:Q3");
|
||||
|
||||
// Notes of about 300 bytes, until the big fork's list page is the refusal.
|
||||
let note = "a long reason, ".repeat(20);
|
||||
let mut sent = 0_u32;
|
||||
let refused = loop {
|
||||
let listed = ask(&desk, "m-g", &forks_page("x", Some("c2")));
|
||||
if refusal_of(&listed).is_some() {
|
||||
break listed;
|
||||
}
|
||||
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
|
||||
sent += 1;
|
||||
assert!(sent < 400, "the record does not grow");
|
||||
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{sent:012}"));
|
||||
if let DeskRequest::Answer { request, .. } = &mut grow {
|
||||
request.fork_id = big.into();
|
||||
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
|
||||
}
|
||||
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
|
||||
};
|
||||
|
||||
// (1) The list names the fork and its order, and nothing else is new in the text.
|
||||
let (code, text) = refusal_of(&refused).unwrap();
|
||||
assert_eq!(code, "fork_too_large", "{refused:?}");
|
||||
assert!(text.contains(big) && text.contains("order 1"), "{text}");
|
||||
|
||||
// (2) The cursor c<order> resumes to the older fork, and order 0 is the end. The newest fork pages
|
||||
// alone ahead of it; the big one is named from the first page too.
|
||||
let first = ask(&desk, "m-p0", &forks_page("x", None));
|
||||
let DeskResponse::Forks { list, .. } = first else { panic!("{first:?}") };
|
||||
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.map(|c| c.order())), (vec![newer.to_string()], Some(2)));
|
||||
let rest = ask(&desk, "m-p1", &forks_page("x", Some("c1")));
|
||||
let DeskResponse::Forks { list, .. } = rest else { panic!("{rest:?}") };
|
||||
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.is_none()), (vec![older.to_string()], true));
|
||||
assert_eq!(list.forks[0].card.order, 0);
|
||||
|
||||
// (3) A read of the same fork: its whole entry while it fits the response bound.
|
||||
let read = ask(&desk, "m-r", &fork_read("x", big));
|
||||
let DeskResponse::Fork { entry, .. } = &read else { panic!("a read of the oversize fork is its entry, not a refusal: {read:?}") };
|
||||
assert_eq!((entry.card.fork_id.as_str(), entry.card.order), (big, 1));
|
||||
// Past DESK_MAX_RESPONSE_BYTES the read is the fork-only fork_too_large (no order, no cursor).
|
||||
let mut more = 0_u32;
|
||||
let too_big = loop {
|
||||
let read = ask(&desk, "m-r", &fork_read("x", big));
|
||||
if matches!(read, DeskResponse::NotStored(_)) {
|
||||
break read;
|
||||
}
|
||||
assert!(matches!(read, DeskResponse::Fork { .. }), "{read:?}");
|
||||
more += 1;
|
||||
assert!(more < 400, "the record does not grow");
|
||||
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{:012}", 1000 + more));
|
||||
if let DeskRequest::Answer { request, .. } = &mut grow {
|
||||
request.fork_id = big.into();
|
||||
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
|
||||
}
|
||||
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
|
||||
};
|
||||
let (code, text) = refusal_of(&too_big).unwrap();
|
||||
assert!(code == "fork_too_large" && text.contains(big) && !text.contains("order"), "{text}");
|
||||
assert!(refusal_of(&ask(&desk, "m-g", &forks_page("x", Some("c2")))).is_some_and(|(code, _)| code == "fork_too_large"));
|
||||
|
||||
// (4) `authority` on a read is still refused whole (the contract's request_invalid is the door's payload-not-a-request).
|
||||
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", fork_read("x", big))] {
|
||||
let mut value = serde_json::to_value(&request).unwrap();
|
||||
value[if operation == "forks" { "Forks" } else { "Fork" }]["authority"] = serde_json::json!("Operator");
|
||||
let message = CultNetMessage::OperationRequest {
|
||||
message_id: "m-ra".into(),
|
||||
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
|
||||
operation: operation.into(),
|
||||
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
|
||||
payload_encoding: "messagepack-base64".into(),
|
||||
payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
};
|
||||
let reply = exchange(&desk, &framed(&message)).expect("the door replied");
|
||||
assert_eq!(decode_desk_response(&reply).unwrap().1.unwrap_err().code, "payload-not-a-request", "{operation}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Q1 (order 0), Q2, Q3 held; the daemon up. Returns what the tests need.
|
||||
fn three_forks() -> (tempfile::TempDir, tempfile::TempDir, String, PathBuf, Daemon) {
|
||||
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let hash = seed(root.path());
|
||||
{
|
||||
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
|
||||
let documents = ["Q2", "Q3"]
|
||||
.into_iter()
|
||||
.map(|label| PipelineDocument::Question(PipelineQuestion {
|
||||
campaign: "eureka-state".into(),
|
||||
label: label.into(),
|
||||
title: Title("Who owns the state?".into()),
|
||||
question: "Who owns the state?".into(),
|
||||
options: vec![
|
||||
QuestionOption { label: "A".into(), text: "the organ".into() },
|
||||
QuestionOption { label: "B".into(), text: "the client".into() },
|
||||
],
|
||||
recommended: "A".into(),
|
||||
depends: vec![],
|
||||
raised_in: None,
|
||||
asked_on: Date("2026-09-16".into()),
|
||||
}))
|
||||
.collect();
|
||||
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
|
||||
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
|
||||
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
||||
}
|
||||
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
|
||||
let daemon = spawn(root.path(), &desk, my_uid(), &log);
|
||||
(root, run, hash, desk, daemon)
|
||||
}
|
||||
|
||||
/// Grows `fork`'s record over the socket until the list page at `cursor` is the refusal; returns it.
|
||||
fn grown_until_refused(desk: &Path, hash: &str, fork: &str, cursor: Option<&str>) -> DeskResponse {
|
||||
let note = "a long reason, ".repeat(20);
|
||||
for sent in 1..400_u32 {
|
||||
let listed = ask(desk, "m-g", &forks_page("x", cursor));
|
||||
if refusal_of(&listed).is_some() {
|
||||
return listed;
|
||||
}
|
||||
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
|
||||
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
|
||||
if let DeskRequest::Answer { request, .. } = &mut grow {
|
||||
request.fork_id = fork.into();
|
||||
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
|
||||
}
|
||||
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
|
||||
}
|
||||
panic!("the record does not grow")
|
||||
}
|
||||
|
||||
fn page_ids(response: DeskResponse) -> (Vec<String>, Option<u64>) {
|
||||
let DeskResponse::Forks { list, .. } = response else { panic!("{response:?}") };
|
||||
(list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect(), list.next_cursor.map(|c| c.order()))
|
||||
}
|
||||
|
||||
/// The oldest fork (order 0) oversize: the list names it and its order, and says it is the end, not a cursor.
|
||||
#[test]
|
||||
fn the_oldest_fork_too_large_says_the_list_ends_and_names_no_cursor() {
|
||||
let _serial = serial();
|
||||
let (_root, _run, hash, desk, _daemon) = three_forks();
|
||||
let refused = grown_until_refused(&desk, &hash, FORK, Some("c1"));
|
||||
let (code, text) = refusal_of(&refused).unwrap();
|
||||
assert_eq!(code, "fork_too_large");
|
||||
assert!(text.contains(FORK) && text.contains("order 0") && !text.contains("c0"), "{text}");
|
||||
}
|
||||
|
||||
/// The newest fork (order 2) oversize: the first page is the refusal, and c2 resumes to the older forks.
|
||||
#[test]
|
||||
fn the_newest_fork_too_large_is_named_and_its_cursor_resumes_to_the_older_forks() {
|
||||
let _serial = serial();
|
||||
let (_root, _run, hash, desk, _daemon) = three_forks();
|
||||
let newest = "eureka-state:question:Q3";
|
||||
let refused = grown_until_refused(&desk, &hash, newest, None);
|
||||
let (code, text) = refusal_of(&refused).unwrap();
|
||||
assert_eq!(code, "fork_too_large");
|
||||
assert!(text.contains(newest) && text.contains("order 2") && text.contains("c2"), "{text}");
|
||||
let rest = page_ids(ask(&desk, "m-p", &forks_page("x", Some("c2"))));
|
||||
assert_eq!(rest, (vec!["eureka-state:question:Q2".to_string(), FORK.to_string()], None));
|
||||
}
|
||||
|
||||
/// Grows `fork` over the socket until a READ of it no longer fits the reply cap; returns the raw reply.
|
||||
fn grown_until_read_overflows(desk: &Path, hash: &str, fork: &str) -> CultNetMessage {
|
||||
let note = "a long reason, ".repeat(60);
|
||||
for sent in 1..400_u32 {
|
||||
let reply = exchange(desk, &desk_bytes("m-read", &fork_read("x", fork))).expect("the door replied");
|
||||
if !matches!(decode_desk_response(&reply).unwrap().1, Ok(DeskResponse::Fork { .. })) {
|
||||
return reply;
|
||||
}
|
||||
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
|
||||
if let DeskRequest::Answer { request, .. } = &mut grow {
|
||||
request.fork_id = fork.into();
|
||||
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
|
||||
}
|
||||
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
|
||||
}
|
||||
panic!("the record does not grow")
|
||||
}
|
||||
|
||||
/// A READ of a fork whose entry is past the reply cap is `fork_too_large` naming the fork, with no
|
||||
/// order and no cursor; a fitting fork still reads; the list's refusal is unchanged; the generic
|
||||
/// response-too-large stays for every other reply.
|
||||
#[test]
|
||||
fn a_read_of_an_oversize_fork_is_fork_too_large_naming_only_the_fork() {
|
||||
let _serial = serial();
|
||||
let (_root, _run, hash, desk, _daemon) = three_forks();
|
||||
let big = "eureka-state:question:Q2";
|
||||
let reply = grown_until_read_overflows(&desk, &hash, big);
|
||||
let (id, response) = decode_desk_response(&reply).unwrap();
|
||||
assert_eq!(id, "m-read");
|
||||
let (code, text) = refusal_of(&response.unwrap()).expect("a not-stored refusal");
|
||||
assert_eq!(code, "fork_too_large");
|
||||
assert!(text.contains(big) && !text.contains("order") && !text.contains("cursor") && !text.contains(" c1"), "{text}");
|
||||
// Fitting forks read, the others untouched.
|
||||
for fork in [FORK, "eureka-state:question:Q3"] {
|
||||
assert!(matches!(ask(&desk, "m-ok", &fork_read("x", fork)), DeskResponse::Fork { .. }));
|
||||
}
|
||||
// The list still names the fork and its order, and the cursor goes on.
|
||||
let listed = ask(&desk, "m-l", &forks_page("x", Some("c2")));
|
||||
let (code, text) = refusal_of(&listed).unwrap();
|
||||
assert_eq!(code, "fork_too_large");
|
||||
assert!(text.contains(big) && text.contains("order 1") && text.contains("c1"), "{text}");
|
||||
}
|
||||
|
||||
/// The encoded size of the reply to a read of `fork` sent under `id`, and the reply. The door echoes the id,
|
||||
/// so its length moves the reply one byte at a time where a record's growth moves it in base64 steps of four.
|
||||
fn read_size(desk: &Path, fork: &str, id: &str) -> (usize, CultNetMessage) {
|
||||
let mut stream = UnixStream::connect(desk).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
stream.write_all(&desk_bytes(id, &fork_read("x", fork))).unwrap();
|
||||
let mut header = [0_u8; 4];
|
||||
stream.read_exact(&mut header).unwrap();
|
||||
let mut payload = vec![0_u8; u32::from_be_bytes(header) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
(payload.len(), decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0).unwrap())
|
||||
}
|
||||
|
||||
/// The reply cap is exact on a READ: a reply of exactly DESK_MAX_RESPONSE_BYTES is the entry, and the same
|
||||
/// read one byte longer is `fork_too_large` naming the fork. The record is grown to within 250 bytes of the
|
||||
/// cap, then the echoed message id is padded byte by byte to land on it.
|
||||
#[test]
|
||||
fn a_read_reply_of_exactly_the_cap_is_the_entry_and_one_byte_more_is_fork_too_large() {
|
||||
let _serial = serial();
|
||||
let cap = huginn_mind::desk_wire::DESK_MAX_RESPONSE_BYTES;
|
||||
let (_root, _run, hash, desk, _daemon) = three_forks();
|
||||
let big = "eureka-state:question:Q2";
|
||||
let mut sent = 0_u32;
|
||||
loop {
|
||||
let (size, reply) = read_size(&desk, big, "m-size");
|
||||
assert!(matches!(decode_desk_response(&reply).unwrap().1, Ok(DeskResponse::Fork { .. })), "grew past the cap at {size}");
|
||||
let remaining = cap - size;
|
||||
if remaining <= 250 {
|
||||
break;
|
||||
}
|
||||
// An answer costs about 513 plus 4/3 of its note (base64); aim the last one at 170 bytes short of the cap.
|
||||
let note = if remaining > 5000 { 2000 } else { (remaining - 170 - 513) * 3 / 4 };
|
||||
sent += 1;
|
||||
let mut request = answer("x", &hash, &format!("00000000-0000-4000-8000-{sent:012}"));
|
||||
if let DeskRequest::Answer { request, .. } = &mut request {
|
||||
request.fork_id = big.into();
|
||||
request.answer = Some(ForkAnswer { choice: None, note: Some("a".repeat(note)) });
|
||||
}
|
||||
assert!(matches!(ask(&desk, "m-w", &request), DeskResponse::Answered { .. }));
|
||||
}
|
||||
let sizes: Vec<(usize, String)> = (0..250).map(|pad| format!("m-size{}", "x".repeat(pad))).map(|id| (read_size(&desk, big, &id).0, id)).collect();
|
||||
let (at_cap, id) = sizes.iter().find(|(size, _)| *size == cap).expect("a padded id lands exactly on the cap").clone();
|
||||
let (_, reply) = read_size(&desk, big, &id);
|
||||
let response = decode_desk_response(&reply).unwrap().1;
|
||||
assert!(matches!(response, Ok(DeskResponse::Fork { .. })), "{at_cap} bytes: {response:?}");
|
||||
let (over, reply) = read_size(&desk, big, &format!("{id}x"));
|
||||
let (_, response) = decode_desk_response(&reply).unwrap();
|
||||
let (code, text) = refusal_of(&response.unwrap()).expect("a not-stored refusal");
|
||||
assert!(code == "fork_too_large" && text.contains(big) && !text.contains("cursor") && !text.contains("order"), "{text}");
|
||||
assert!(over < cap, "the refusal itself is small: {over}");
|
||||
}
|
||||
|
||||
/// A stale answer is STORED as a refusal (`stale_fork`), never counted, and its replay returns the same
|
||||
/// stored refusal from the door even once the fork is too large to read: nothing new is stored, and the
|
||||
/// reply is neither `fork_too_large` nor `ResponseTooLarge` (a stored record carries no card, so it is small).
|
||||
#[test]
|
||||
fn a_stale_answer_is_stored_refused_and_its_replay_survives_the_fork_growing_too_large() {
|
||||
let _serial = serial();
|
||||
let (_root, _run, hash, desk, daemon) = three_forks();
|
||||
let fork = "eureka-state:question:Q2";
|
||||
let mut stale = answer("x", &"0".repeat(64), "00000000-0000-4000-8000-0000ffff0001");
|
||||
if let DeskRequest::Answer { request, .. } = &mut stale {
|
||||
request.fork_id = fork.into();
|
||||
}
|
||||
let before = daemon.documents();
|
||||
let DeskResponse::Answered { answer: first, counted_answer_id, .. } = ask(&desk, "m-s1", &stale) else { panic!("a stale answer is stored") };
|
||||
assert_eq!((first.code, first.outcome.as_str(), counted_answer_id), (Some(ForkAnswerCode::StaleFork), "Refused", None));
|
||||
let stored = daemon.documents();
|
||||
assert_eq!(stored, before + 1, "the refusal is a stored record");
|
||||
let DeskResponse::Fork { entry, .. } = ask(&desk, "m-s2", &fork_read("x", fork)) else { panic!("a read") };
|
||||
assert!(entry.record.answers.iter().any(|a| a.answer_id == first.answer_id && a.code == Some(ForkAnswerCode::StaleFork)));
|
||||
assert_eq!(entry.record.counted_answer_id, None);
|
||||
// The fork grows until it cannot be read.
|
||||
let reply = grown_until_read_overflows(&desk, &hash, fork);
|
||||
let (_, response) = decode_desk_response(&reply).unwrap();
|
||||
assert_eq!(refusal_of(&response.unwrap()).unwrap().0, "fork_too_large");
|
||||
let grown = daemon.documents();
|
||||
// The identical request replays the stored refusal: same answer id, nothing stored.
|
||||
let DeskResponse::Answered { answer: replay, counted_answer_id, .. } = ask(&desk, "m-s1", &stale) else { panic!("the replay is the stored answer") };
|
||||
assert_eq!((replay.answer_id.clone(), replay.code, replay.outcome.as_str()), (first.answer_id.clone(), Some(ForkAnswerCode::StaleFork), "Refused"));
|
||||
assert_ne!(counted_answer_id.as_deref(), Some(replay.answer_id.as_str()), "the refusal is never the counted answer");
|
||||
assert_eq!(daemon.documents(), grown, "the replay stored nothing");
|
||||
}
|
||||
|
|
@ -2345,6 +2345,22 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![D::Ruling(quoted)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steward_ruling_takes_no_operator_quote() {
|
||||
let mut mind = seeded();
|
||||
let mut peer = ruling("R1");
|
||||
peer.authority = RulingAuthority::Steward {
|
||||
steward: "@heimdall:chat".into(),
|
||||
repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()),
|
||||
words: "refuse leading zeros".into(),
|
||||
event: None,
|
||||
};
|
||||
peer.operator_quote = Some("all recommendations, go ahead".into());
|
||||
assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(peer.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") });
|
||||
peer.operator_quote = None;
|
||||
committed(admit(&mut mind, vec![D::Ruling(peer)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mind_ruling_without_a_quote_is_admitted() {
|
||||
let mut mind = seeded();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
//! request id). It builds one `desk_answer` and commits it through
|
||||
//! `admit_steps` with `Door::Desk`, the same commit primitive every other write
|
||||
//! uses; no other door may write the kind. `answer_desk` and `DeskStamp` are
|
||||
//! crate-private: only code inside this crate can stamp, so the verified door
|
||||
//! that will call them (follow-up desk-answer-door) widens them in its own cut.
|
||||
//! crate-private: only code inside this crate can stamp.
|
||||
//!
|
||||
//! The decider list is an input (`DeskStamp.deciders`, from the door); the Mind
|
||||
//! never reads one. Whether an answerer decides is membership in that list and
|
||||
|
|
@ -125,7 +124,7 @@ pub fn options_hash(question: &PipelineQuestion) -> Sha256Hex {
|
|||
}
|
||||
|
||||
/// Every desk answer of one fork, with its key.
|
||||
fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator<Item = (&'a str, &'a PipelineDeskAnswer)> + 'a {
|
||||
pub(crate) fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator<Item = (&'a str, &'a PipelineDeskAnswer)> + 'a {
|
||||
docs.of_kind(PipelineKind::DeskAnswer).filter_map(move |(key, document)| match document {
|
||||
PipelineDocument::DeskAnswer(answer) if answer.fork.0 == fork => Some((key, answer)),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! The fork desk's answer wire, Huginn's half: Libby's answer-side documents
|
||||
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at 6231ecad)
|
||||
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at e57fbaee)
|
||||
//! as Rust types, the one conversion between her `n<seq>` answer ids and the
|
||||
//! record keys, the projection of a stored answer, the refusal mapping and the
|
||||
//! showability predicate, and `Mind::desk_answer`, the one call that runs them in order.
|
||||
//! The read side (card, record, entry and list, `Mind::desk_forks` and `Mind::desk_fork`)
|
||||
//! is the child module `reads`.
|
||||
//!
|
||||
//! `Mind::answer_desk` stays the only owner of an answer's outcome, sequence,
|
||||
//! request id lookup, replay and conflict; this module converts, projects and
|
||||
|
|
@ -10,12 +12,15 @@
|
|||
//! stays the only owner of "counts": the response builder calls it at the head
|
||||
//! the response is read at, and nothing here stores it. Huginn writes
|
||||
//! `gamecult.desk_answer.v0` and its own refusals; the stale-fork display
|
||||
//! and the answer result are Bifrost's to attach and write. The envelope, the published
|
||||
//! schemas, the reads and the socket are other cuts.
|
||||
//! and the answer result are Bifrost's to attach and write. The request type, its
|
||||
//! constants and the published schemas are here; the socket is the daemon's.
|
||||
|
||||
mod reads;
|
||||
pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkEntry, ForkLimit, ForkList, ForkOption, ForkRecord, ForkRef, ListSchema, ListStatus, RecordSchema};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use eureka_pipeline::{
|
||||
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Words,
|
||||
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -55,7 +60,7 @@ fn required<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(deserializer:
|
|||
}
|
||||
|
||||
/// `gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the
|
||||
/// shape step of `Mind::answer_desk` judges it. The subject and the attribution are the
|
||||
/// shape step of `Mind::answer_desk` judges it. The subject and the authority are the
|
||||
/// door's context and are no field here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
|
@ -147,13 +152,14 @@ pub struct ForkDeskAnswer {
|
|||
pub request_id: String,
|
||||
}
|
||||
|
||||
/// The three requests Huginn refuses without storing, by code.
|
||||
/// The refusals Huginn sends without storing, by code. `ForkTooLarge` is the list's alone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ForkRefusalCode {
|
||||
ForkUnknown,
|
||||
BodyInvalid,
|
||||
RequestIdConflict,
|
||||
ForkTooLarge,
|
||||
}
|
||||
|
||||
/// `gamecult.fork.refusal.v0`. `error` is a fixed sentence, never an echo of what was sent;
|
||||
|
|
@ -169,17 +175,173 @@ pub struct ForkRefusal {
|
|||
|
||||
/// What the door answers one desk request with. `Answered` is a stored answer, refused ones
|
||||
/// included (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.
|
||||
/// `NotStored` is a request that stored nothing. `Refused` is any other Mind refusal; an
|
||||
/// `Unavailable` is Bifrost's to turn into its 503, and Huginn writes no document for it.
|
||||
/// `Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were
|
||||
/// all read at. `NotStored` is a request that stored nothing (an unknown fork included).
|
||||
/// `Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and
|
||||
/// Huginn writes no document for it.
|
||||
///
|
||||
/// The trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`
|
||||
/// carry the WHOLE record, every answer's note included, to whatever the peer-checked socket
|
||||
/// carries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a
|
||||
/// stripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable
|
||||
/// only by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a
|
||||
/// second reader of that socket is a note leak.
|
||||
// One value per call, built and encoded once; a box would only add a deref to every reader.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum DeskResponse {
|
||||
Answered { answer: ForkDeskAnswer, counted_answer_id: Option<String>, as_of: u64 },
|
||||
Forks { list: ForkList, as_of: u64 },
|
||||
Fork { entry: ForkEntry, as_of: u64 },
|
||||
NotStored(ForkRefusal),
|
||||
Refused(MindRefusal),
|
||||
}
|
||||
|
||||
impl DeskResponse {
|
||||
/// The envelope's status: `rejected` exactly when nothing was stored or the Mind refused.
|
||||
pub fn status(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Answered { .. } | Self::Forks { .. } | Self::Fork { .. } => "accepted",
|
||||
Self::NotStored(_) | Self::Refused(_) => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The envelope's `service_id` for the desk door.
|
||||
pub const DESK_SERVICE_ID: &str = "huginn.desk";
|
||||
pub const DESK_REQUEST_SCHEMA: &str = "huginn.desk_request.v1";
|
||||
pub const DESK_RESPONSE_SCHEMA: &str = "huginn.desk_response.v1";
|
||||
|
||||
/// The published schemas, derived from `DeskRequest` and `DeskResponse` and pinned byte for byte
|
||||
/// by `published_desk_schemas_match_derivation`.
|
||||
pub const DESK_REQUEST_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_request.v1.schema.json");
|
||||
pub const DESK_RESPONSE_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_response.v1.schema.json");
|
||||
|
||||
/// The frame bounds the door enforces, on the encoded CultNet message.
|
||||
pub const DESK_MAX_REQUEST_BYTES: usize = 65536;
|
||||
pub const DESK_MAX_RESPONSE_BYTES: usize = 65536;
|
||||
/// What a page of forks leaves for the envelope around it: the echoed message id (at most 256
|
||||
/// bytes), the operation, the runtime id and the frame's own keys. `Mind::desk_forks` fills a page
|
||||
/// to `DESK_PAGE_BUDGET`, the response bound less this, measured on the bare message.
|
||||
pub const DESK_ENVELOPE_MARGIN: usize = 1024;
|
||||
pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARGIN;
|
||||
|
||||
/// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
|
||||
pub const DESK_DECIDERS_MAX: usize = 64;
|
||||
|
||||
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` subjects, none twice. A
|
||||
/// bad list is the decoder's refusal, which names the field and never quotes a subject.
|
||||
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> {
|
||||
struct List;
|
||||
impl<'de> serde::de::Visitor<'de> for List {
|
||||
type Value = Vec<Short>;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a list of distinct deciders")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Short>, A::Error> {
|
||||
let mut list = Vec::new();
|
||||
while let Some(subject) = seq.next_element::<Short>()? {
|
||||
if list.len() == DESK_DECIDERS_MAX {
|
||||
return Err(serde::de::Error::custom("too many deciders"));
|
||||
}
|
||||
if list.contains(&subject) {
|
||||
return Err(serde::de::Error::custom("a decider is listed twice"));
|
||||
}
|
||||
list.push(subject);
|
||||
}
|
||||
if list.is_empty() {
|
||||
return Err(serde::de::Error::custom("no deciders"));
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(List)
|
||||
}
|
||||
|
||||
fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::json_schema!({
|
||||
"type": "array",
|
||||
"items": generator.subschema_for::<Short>(),
|
||||
"minItems": 1,
|
||||
"maxItems": DESK_DECIDERS_MAX,
|
||||
"uniqueItems": true
|
||||
})
|
||||
}
|
||||
|
||||
/// Every request the desk door answers. The fields are the wire names: the `mind` addressed
|
||||
/// (the persona and pipeline requests say `instance`; the sets are separate), the verified
|
||||
/// `subject` and `authority` Bifrost vouches for (door context, never body fields), and the
|
||||
/// `deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside
|
||||
/// the set refuses the whole payload.
|
||||
///
|
||||
/// The reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and
|
||||
/// `read_request` are closed and have none; one that names it is refused whole). Their `subject`
|
||||
/// and `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability
|
||||
/// gate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat
|
||||
/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn
|
||||
/// answers every read with the whole record and Bifrost applies visibility; see `DeskResponse`
|
||||
/// for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
|
||||
/// reads spec.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DeskRequest {
|
||||
Answer {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
authority: DeskAuthority,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
request: ForkAnswerRequest,
|
||||
},
|
||||
Forks {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
#[serde(deserialize_with = "required")]
|
||||
#[schemars(required)]
|
||||
status: Option<ListStatus>,
|
||||
#[serde(deserialize_with = "required")]
|
||||
#[schemars(required)]
|
||||
cursor: Option<ForkCursor>,
|
||||
limit: ForkLimit,
|
||||
},
|
||||
Fork {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
fork_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl DeskRequest {
|
||||
/// The envelope's `operation`.
|
||||
pub fn operation(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Answer { .. } => "answer",
|
||||
Self::Forks { .. } => "forks",
|
||||
Self::Fork { .. } => "fork",
|
||||
}
|
||||
}
|
||||
|
||||
/// The mind the request addresses, for `Mind::require_instance`.
|
||||
pub fn mind(&self) -> &Slug {
|
||||
match self {
|
||||
Self::Answer { mind, .. } | Self::Forks { mind, .. } | Self::Fork { mind, .. } => mind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deciders(&self) -> &[Short] {
|
||||
match self {
|
||||
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the one conversion between her answer ids and the record keys ----
|
||||
|
||||
/// The key prefix of one fork's answers. The only place a desk answer key is spelled.
|
||||
|
|
@ -221,6 +383,19 @@ fn fork_unknown() -> ForkRefusal {
|
|||
fork_refusal(ForkRefusalCode::ForkUnknown, "fork_unknown: no such fork".into())
|
||||
}
|
||||
|
||||
/// A list page whose first entry alone is past the page budget: names that fork and its `order`. A
|
||||
/// client continues with the cursor `c<order>`; at order 0 there is no such cursor and the list ends.
|
||||
fn fork_too_large(fork: &str, order: u64) -> ForkRefusal {
|
||||
let onward = if order == 0 { "this is the end of the list".to_string() } else { format!("continue with cursor c{order}") };
|
||||
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} at order {order} is past the page budget by itself; {onward}"))
|
||||
}
|
||||
|
||||
/// A READ of one fork whose entry is past the reply cap: names the fork, nothing else (a read has no
|
||||
/// order to continue from and no cursor). The door raises it where it measures the reply.
|
||||
pub fn fork_too_large_read(fork: &str) -> ForkRefusal {
|
||||
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} is past the response bound by itself"))
|
||||
}
|
||||
|
||||
fn body_invalid(field: &str) -> ForkRefusal {
|
||||
fork_refusal(ForkRefusalCode::BodyInvalid, format!("body_invalid: the body does not satisfy gamecult.fork.answer_request.v0 ({field})"))
|
||||
}
|
||||
|
|
@ -294,10 +469,15 @@ fn project(key: &str, stored: &PipelineDeskAnswer) -> Result<ForkDeskAnswer, Min
|
|||
/// question id, or whose id is over the 200-byte bound, names no fork), a body the shape step
|
||||
/// refused and a reused request id store nothing and have codes of their own; anything else,
|
||||
/// `Unavailable` included, passes through unchanged.
|
||||
/// The Mind's `DeskRequestInvalid` fields that come from the fork id alone.
|
||||
const FORK_FIELDS: [&str; 4] = ["fork", "desk_answer.fork", "desk_answer.key", "desk_answer.campaign"];
|
||||
|
||||
fn door_refusal(refused: MindRefusal) -> DeskResponse {
|
||||
match refused {
|
||||
MindRefusal::MissingReference { .. } | MindRefusal::CampaignMoved { .. } => DeskResponse::NotStored(fork_unknown()),
|
||||
MindRefusal::DeskRequestInvalid { field } if field == "fork" || field == "desk_answer.fork" => DeskResponse::NotStored(fork_unknown()),
|
||||
// Any bad fork id is `fork_unknown`. The Mind derives these four fields from the fork id alone
|
||||
// (the id's shape, its campaign root, the record key built from it), so each one is the fork.
|
||||
MindRefusal::DeskRequestInvalid { field } if FORK_FIELDS.contains(&field.as_str()) => DeskResponse::NotStored(fork_unknown()),
|
||||
MindRefusal::DeskRequestInvalid { field } => DeskResponse::NotStored(body_invalid(&field)),
|
||||
MindRefusal::DeskRequestIdConflict { .. } => {
|
||||
DeskResponse::NotStored(fork_refusal(ForkRefusalCode::RequestIdConflict, "request_id_conflict: this request_id was used for a different body".into()))
|
||||
|
|
@ -317,21 +497,20 @@ fn showable(question: &PipelineQuestion, deciders: &[Short]) -> bool {
|
|||
|
||||
impl<S: MindStore> Mind<S> {
|
||||
/// One desk answer from the door: the wire request converted, the question gated, the answer
|
||||
/// decided and stored (or replayed) by `answer_desk`, and the stored answer projected with the
|
||||
/// derived count at the head it was read at. The flag is `true` when nothing was committed (a
|
||||
/// replay); it is for the door and is never encoded. `subject` and `attribution` are the verified
|
||||
/// decided and stored (or committed) by `answer_desk`, and the stored answer projected with the
|
||||
/// derived count at the head it was read at. `subject` and `authority` are the verified
|
||||
/// caller's, `deciders` the door's list.
|
||||
pub fn desk_answer(
|
||||
&mut self,
|
||||
deciders: &[Short],
|
||||
subject: Short,
|
||||
attribution: DeskAuthority,
|
||||
authority: DeskAuthority,
|
||||
request: ForkAnswerRequest,
|
||||
now: DateTime<Utc>,
|
||||
) -> (DeskResponse, bool) {
|
||||
match self.desk_response(deciders, subject, attribution, &request, now) {
|
||||
) -> DeskResponse {
|
||||
match self.desk_response(deciders, subject, authority, &request, now) {
|
||||
Ok(done) => done,
|
||||
Err(refused) => (door_refusal(refused), false),
|
||||
Err(refused) => door_refusal(refused),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,13 +518,13 @@ impl<S: MindStore> Mind<S> {
|
|||
&mut self,
|
||||
deciders: &[Short],
|
||||
subject: Short,
|
||||
attribution: DeskAuthority,
|
||||
authority: DeskAuthority,
|
||||
request: &ForkAnswerRequest,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(DeskResponse, bool), MindRefusal> {
|
||||
) -> Result<DeskResponse, MindRefusal> {
|
||||
let sent = match request_of(request) {
|
||||
Ok(sent) => sent,
|
||||
Err(refused) => return Ok((DeskResponse::NotStored(refused), false)),
|
||||
Err(refused) => return Ok(DeskResponse::NotStored(refused)),
|
||||
};
|
||||
let image = self.image()?;
|
||||
let docs = Docs::at(image, image.head);
|
||||
|
|
@ -354,12 +533,11 @@ impl<S: MindStore> Mind<S> {
|
|||
let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none()
|
||||
&& matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders));
|
||||
if unshowable {
|
||||
return Ok((DeskResponse::NotStored(fork_unknown()), false));
|
||||
return Ok(DeskResponse::NotStored(fork_unknown()));
|
||||
}
|
||||
let stamp = DeskStamp { answerer: subject, authority: attribution, deciders: deciders.to_vec() };
|
||||
let (key, replayed) = match self.answer_desk(stamp, sent, now) {
|
||||
DeskAnswerOutcome::Stored { answer, .. } => (answer.0, false),
|
||||
DeskAnswerOutcome::Replayed { answer, .. } => (answer.0, true),
|
||||
let stamp = DeskStamp { answerer: subject, authority, deciders: deciders.to_vec() };
|
||||
let key = match self.answer_desk(stamp, sent, now) {
|
||||
DeskAnswerOutcome::Stored { answer, .. } | DeskAnswerOutcome::Replayed { answer, .. } => answer.0,
|
||||
DeskAnswerOutcome::Refused(refused) => return Err(refused),
|
||||
};
|
||||
let image = self.image()?;
|
||||
|
|
@ -367,7 +545,7 @@ impl<S: MindStore> Mind<S> {
|
|||
let Some(PipelineDocument::DeskAnswer(stored)) = docs.find(PipelineKind::DeskAnswer, &key) else { return Err(unreadable_key()) };
|
||||
let fork = stored.fork.0.as_str();
|
||||
let counted_answer_id = counted_answer(&docs, fork).map(|counted| answer_seq(fork, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?;
|
||||
Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head }, replayed))
|
||||
Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,8 +633,12 @@ mod tests {
|
|||
list.iter().map(|who| s(who)).collect()
|
||||
}
|
||||
|
||||
/// The response and whether the call committed anything: a replay, a refused body and a gate
|
||||
/// refusal store nothing, so the head does not move.
|
||||
fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) {
|
||||
mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now())
|
||||
let before = head(mind);
|
||||
let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now());
|
||||
(response, head(mind) != before)
|
||||
}
|
||||
|
||||
fn ask(mind: &mut Mind<MemoryStore>, who: &str, request: ForkAnswerRequest) -> (DeskResponse, bool) {
|
||||
|
|
@ -717,9 +899,9 @@ mod tests {
|
|||
assert_eq!((answer.outcome.as_str(), answer.retracts.as_deref(), counted.as_deref(), as_of), ("Retraction", Some("n1"), Some("n2"), head(&mind)));
|
||||
// The first request replayed: its stored word is still Counted, the count is read now.
|
||||
let before = head(&mind);
|
||||
let (response, replayed) = ask(&mut mind, "x", first);
|
||||
let (response, committed) = ask(&mut mind, "x", first);
|
||||
let (answer, counted, as_of) = answered(response);
|
||||
assert!(replayed);
|
||||
assert!(!committed);
|
||||
assert_eq!((answer.outcome.as_str(), answer.answer_id.as_str(), counted.as_deref(), as_of), ("Counted", "n1", Some("n2"), before));
|
||||
assert_eq!(head(&mind), before);
|
||||
// The second request replayed now counts, and its stored word is still Revision.
|
||||
|
|
@ -855,14 +1037,14 @@ mod tests {
|
|||
];
|
||||
for (form, request) in forms.into_iter().enumerate() {
|
||||
let stored = stored_count(&mind, FORK);
|
||||
let (response, replayed) = ask(&mut mind, "x", request.clone());
|
||||
assert!(!replayed);
|
||||
let (response, committed) = ask(&mut mind, "x", request.clone());
|
||||
assert!(committed);
|
||||
let (answer, _, _) = answered(response.clone());
|
||||
assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::BadRetraction)), "form {form}");
|
||||
assert_eq!(stored_count(&mind, FORK), stored + 1, "form {form}");
|
||||
// The refusal is stored and bound: the same bytes replay, with no write.
|
||||
let before = head(&mind);
|
||||
assert_eq!(ask(&mut mind, "x", request), (response, true), "form {form}");
|
||||
assert_eq!(ask(&mut mind, "x", request), (response, false), "form {form}");
|
||||
assert_eq!(head(&mind), before);
|
||||
}
|
||||
// (i) and (iii) with equal answer and target differ only in `retracted`: one id, two bodies.
|
||||
|
|
@ -924,7 +1106,7 @@ mod tests {
|
|||
}
|
||||
assert_eq!(head(&mind), before);
|
||||
// The mapping is by exact field: a field that merely ends in "fork" is a bad body.
|
||||
for field in ["desk_answer.fork", "fork"] {
|
||||
for field in ["desk_answer.fork", "fork", "desk_answer.key", "desk_answer.campaign"] {
|
||||
assert_eq!(not_stored(door_refusal(MindRefusal::DeskRequestInvalid { field: field.into() })).code, ForkRefusalCode::ForkUnknown, "{field}");
|
||||
}
|
||||
for field in ["notfork", "desk_answer.forks", "desk_answer.fork.x", "request"] {
|
||||
|
|
@ -932,6 +1114,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Any bad fork id is `fork_unknown`, whichever field the Mind faults: the id's shape, its
|
||||
/// campaign root or the record key built from it. A bad field that is not the fork stays `body_invalid`.
|
||||
#[test]
|
||||
fn every_bad_fork_id_is_unknown_and_other_bad_fields_are_not() {
|
||||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
let before = head(&mind);
|
||||
let forks = [
|
||||
"", // fork
|
||||
"eureka-state:epic:Q1", // fork: not a question id
|
||||
"eureka-state:question:Q 1", // desk_answer.fork
|
||||
":question:Q1", // desk_answer.campaign
|
||||
"Bad Root:question:Q1", // desk_answer.campaign
|
||||
"a:question:Q1.2", // desk_answer.key
|
||||
"eureka-state:question:Q99", // a well-formed id nothing holds
|
||||
];
|
||||
for fork in forks {
|
||||
let refusal = not_stored(ask(&mut mind, "x", wire(fork, &hash)).0);
|
||||
assert_eq!(refusal, fork_unknown(), "{fork:?}");
|
||||
assert_eq!(head(&mind), before, "{fork:?}");
|
||||
}
|
||||
let bad_choice = ForkAnswerRequest { answer: Some(ForkAnswer { choice: Some(String::new()), note: None }), ..wire(FORK, &hash) };
|
||||
let refusal = not_stored(ask(&mut mind, "x", bad_choice).0);
|
||||
assert_eq!(refusal.code, ForkRefusalCode::BodyInvalid);
|
||||
assert!(refusal.error.contains("desk_answer.choice"), "{refusal:?}");
|
||||
assert_eq!(head(&mind), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_codes_are_snake_case() {
|
||||
let table = [
|
||||
|
|
@ -960,14 +1170,14 @@ mod tests {
|
|||
fn a_stale_answer_carries_no_card() {
|
||||
let mut mind = desk();
|
||||
let stale = wire(FORK, &"e".repeat(64));
|
||||
let (fresh, replayed) = ask(&mut mind, "x", stale.clone());
|
||||
assert!(!replayed);
|
||||
let (fresh, committed) = ask(&mut mind, "x", stale.clone());
|
||||
assert!(committed);
|
||||
let (answer, _, _) = answered(fresh.clone());
|
||||
assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::StaleFork)));
|
||||
// After the fork closed, the retry still answers the stored refusal, with nothing added.
|
||||
close(&mut mind, FORK, "RQ1", "A");
|
||||
let (again, replayed) = ask(&mut mind, "x", stale);
|
||||
assert!(replayed);
|
||||
let (again, committed) = ask(&mut mind, "x", stale);
|
||||
assert!(!committed);
|
||||
let (again_answer, _, _) = answered(again.clone());
|
||||
assert_eq!(again_answer, answer);
|
||||
for response in [&fresh, &again] {
|
||||
|
|
@ -982,7 +1192,7 @@ mod tests {
|
|||
let request = wire(FORK, &hash);
|
||||
let (fresh, first) = ask(&mut mind, "x", request.clone());
|
||||
let (replay, second) = ask(&mut mind, "x", request);
|
||||
assert_eq!((first, second), (false, true));
|
||||
assert_eq!((first, second), (true, false));
|
||||
assert_eq!(encode(&fresh), encode(&replay));
|
||||
assert_eq!(stored_count(&mind, FORK), 1);
|
||||
}
|
||||
|
|
@ -1031,21 +1241,42 @@ mod tests {
|
|||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
let request = wire(FORK, &hash);
|
||||
let (stored, replayed) = ask(&mut mind, "x", request.clone());
|
||||
assert!(!replayed);
|
||||
let (stored, committed) = ask(&mut mind, "x", request.clone());
|
||||
assert!(committed);
|
||||
let before = head(&mind);
|
||||
// The list Bifrost passes is empty now; the stored request still gets its stored answer.
|
||||
let (again, replayed) = ask_as(&mut mind, "x", &[], request.clone());
|
||||
assert!(replayed);
|
||||
let (again, committed) = ask_as(&mut mind, "x", &[], request.clone());
|
||||
assert!(!committed);
|
||||
assert_eq!(encode(&again), encode(&stored));
|
||||
assert_eq!(head(&mind), before);
|
||||
// Another subject with the same bytes has stored nothing: the gate refuses it.
|
||||
let (other, replayed) = ask_as(&mut mind, "y", &[], request);
|
||||
assert_eq!((not_stored(other).code, replayed), (ForkRefusalCode::ForkUnknown, false));
|
||||
let (other, committed) = ask_as(&mut mind, "y", &[], request);
|
||||
assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false));
|
||||
// And a request nobody stored is fork_unknown while the list is empty.
|
||||
let fresh = wire(FORK, &hash);
|
||||
assert_eq!(not_stored(ask_as(&mut mind, "x", &[], fresh).0).code, ForkRefusalCode::ForkUnknown);
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1));
|
||||
// A stored refusal and a stored revision replay the same way: a stale hash is stored as a
|
||||
// refusal, a second answer by the same subject as a revision.
|
||||
let stale = wire(FORK, "0".repeat(hash.len()).as_str());
|
||||
let (refused, committed) = ask(&mut mind, "x", stale.clone());
|
||||
assert!(committed);
|
||||
assert_eq!(answered(refused.clone()).0.outcome, "Refused");
|
||||
let revision = wire(FORK, &hash);
|
||||
let (revised, committed) = ask(&mut mind, "x", revision.clone());
|
||||
assert!(committed);
|
||||
assert_eq!(answered(revised.clone()).0.outcome, "Revision");
|
||||
let before = head(&mind);
|
||||
for (request, stored) in [(stale, refused), (revision, revised)] {
|
||||
let (again, committed) = ask_as(&mut mind, "x", &[], request.clone());
|
||||
assert!(!committed);
|
||||
// `as_of` is the head the replay was read at, so the stored answer is what is compared.
|
||||
assert_eq!(encode(&answered(again).0), encode(&answered(stored).0));
|
||||
assert_eq!(head(&mind), before);
|
||||
let (other, committed) = ask_as(&mut mind, "w", &[], request);
|
||||
assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false));
|
||||
assert_eq!(head(&mind), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1107,16 +1338,16 @@ mod tests {
|
|||
let (first, _) = ask(&mut mind, "x", request.clone());
|
||||
// V12: the same bytes replay.
|
||||
let before = head(&mind);
|
||||
assert_eq!(ask(&mut mind, "x", request.clone()), (first, true));
|
||||
assert_eq!(ask(&mut mind, "x", request.clone()), (first, false));
|
||||
assert_eq!(head(&mind), before);
|
||||
// V13: only answered_at changed is a conflict, and nothing is stored.
|
||||
let moved = ForkAnswerRequest { answered_at: "2026-11-10T12:00:01Z".into(), ..request.clone() };
|
||||
assert_eq!(not_stored(ask(&mut mind, "x", moved).0).code, ForkRefusalCode::RequestIdConflict);
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1));
|
||||
// V14: another subject with identical bytes is its own request: its own answer id, stamped with it.
|
||||
let (other, replayed) = ask(&mut mind, "y", request.clone());
|
||||
let (other, committed) = ask(&mut mind, "y", request.clone());
|
||||
let (other, _, _) = answered(other);
|
||||
assert!(!replayed);
|
||||
assert!(committed);
|
||||
assert_eq!((other.answer_id.as_str(), other.answerer.as_str(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str()));
|
||||
let (original, _, _) = answered(ask(&mut mind, "x", request.clone()).0);
|
||||
assert_eq!((original.answer_id.as_str(), original.answerer.as_str()), ("n1", "x"));
|
||||
|
|
@ -1274,7 +1505,7 @@ mod tests {
|
|||
let authority = serde_json::from_value(server["authority"].clone()).unwrap();
|
||||
let at = DateTime::parse_from_rfc3339(server["received_at"].as_str().unwrap()).unwrap().to_utc();
|
||||
let answerer = s(server["answerer"].as_str().unwrap());
|
||||
let (mine, _) = mind.desk_answer(&deciders(&list), answerer, authority, request, at);
|
||||
let mine = mind.desk_answer(&deciders(&list), answerer, authority, request, at);
|
||||
if code == Some("unavailable") {
|
||||
continue; // the door went down after the Mind stored: its answer was never seen
|
||||
}
|
||||
|
|
@ -1326,14 +1557,109 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_attribution_is_stamped_and_never_decides() {
|
||||
fn the_authority_is_stamped_and_never_decides() {
|
||||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
// The operator's own account, off the list, is no decider; a member on the list is.
|
||||
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now()).0;
|
||||
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
|
||||
assert_eq!(answered(off).0.code, Some(ForkAnswerCode::NotADecider));
|
||||
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now()).0;
|
||||
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now());
|
||||
let (answer, _, _) = answered(on);
|
||||
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_str()), ("Counted", DeskAuthority::Member, "m"));
|
||||
}
|
||||
|
||||
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----
|
||||
|
||||
use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
|
||||
|
||||
fn list_of(n: usize) -> Vec<Short> {
|
||||
(0..n).map(|at| s(&format!("d{at}"))).collect()
|
||||
}
|
||||
|
||||
/// Each request kind of the desk, carrying `list`.
|
||||
fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] {
|
||||
[
|
||||
DeskRequest::Answer { mind: slug(mind), subject: s("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
|
||||
DeskRequest::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
|
||||
DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
|
||||
]
|
||||
}
|
||||
|
||||
/// The request as a client sends it and the door reads it.
|
||||
fn through_the_door(request: &DeskRequest) -> Result<DeskRequest, OperationFailure> {
|
||||
let message = encode_desk_request("m-1", request, None).unwrap();
|
||||
decode_desk_request(&message).map(|(_, read)| read)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deciders_are_at_most_sixty_four_per_call_on_answer_forks_and_fork() {
|
||||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
let names = ["answer", "forks", "fork"];
|
||||
for (at, sent) in desk_requests(INSTANCE, &list_of(64), &hash).into_iter().enumerate() {
|
||||
let read = through_the_door(&sent).unwrap_or_else(|failure| panic!("{}: 64 deciders refused: {failure:?}", names[at]));
|
||||
assert_eq!((read.operation(), read.deciders().len()), (names[at], 64));
|
||||
assert_eq!(read, sent);
|
||||
}
|
||||
// 64 deciders through to the Mind: the subject is listed and counts.
|
||||
let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&desk_requests(INSTANCE, &list_of(64), &hash)[0]).unwrap() else { panic!("not an answer") };
|
||||
let before = head(&mind);
|
||||
let response = mind.desk_answer(&deciders, subject, authority, request, now());
|
||||
assert!(head(&mind) > before);
|
||||
assert_eq!(answered(response).0.outcome, "Counted");
|
||||
|
||||
let (head_before, stored_before) = (head(&mind), stored_count(&mind, FORK));
|
||||
for (at, sent) in desk_requests(INSTANCE, &list_of(65), &hash).into_iter().enumerate() {
|
||||
let failure = through_the_door(&sent).unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request", "{}", names[at]);
|
||||
assert!(failure.message.contains("deciders"), "{} names the field: {}", names[at], failure.message);
|
||||
assert!(!failure.message.contains("d64"), "{} echoes a decider: {}", names[at], failure.message);
|
||||
}
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), (head_before, stored_before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_or_duplicated_decider_list_is_refused_at_decode_and_one_is_accepted() {
|
||||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
let before = (head(&mind), stored_count(&mind, FORK));
|
||||
let mut twice = list_of(3);
|
||||
twice.push(s("d1"));
|
||||
for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
|
||||
for sent in desk_requests(INSTANCE, &list, &hash) {
|
||||
let failure = through_the_door(&sent).unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request", "{what} {}", sent.operation());
|
||||
assert!(failure.message.contains("deciders") && !failure.message.contains("d1"), "{what} {}: {}", sent.operation(), failure.message);
|
||||
}
|
||||
}
|
||||
// One decider is a list.
|
||||
for sent in desk_requests(INSTANCE, &list_of(1), &hash) {
|
||||
assert_eq!(through_the_door(&sent).unwrap(), sent);
|
||||
}
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
|
||||
// The Mind's showability gate is the second line: an empty list handed to it directly
|
||||
// still refuses the fork and stores nothing.
|
||||
let refused = not_stored(mind.desk_answer(&[], s("d0"), DeskAuthority::Member, wire(FORK, &hash), now()));
|
||||
assert_eq!(refused.code, ForkRefusalCode::ForkUnknown);
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_for_another_mind_is_refused_and_stores_nothing() {
|
||||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
let before = (head(&mind), stored_count(&mind, FORK));
|
||||
for sent in desk_requests(OTHER_INSTANCE, &list_of(4), &hash) {
|
||||
let read = through_the_door(&sent).unwrap();
|
||||
assert_eq!(
|
||||
mind.require_instance(read.mind()),
|
||||
Err(MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() })
|
||||
);
|
||||
}
|
||||
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
|
||||
// The same request to the mind it names is answered.
|
||||
let DeskRequest::Answer { mind: named, subject, authority, deciders, request } = through_the_door(&desk_requests(INSTANCE, &list_of(4), &hash)[0]).unwrap() else { panic!("not an answer") };
|
||||
mind.require_instance(&named).unwrap();
|
||||
assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now())).0.outcome, "Counted");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1516
crates/huginn-mind/src/desk_wire/reads.rs
Normal file
1516
crates/huginn-mind/src/desk_wire/reads.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -69,6 +69,11 @@ impl<'i> Docs<'i> {
|
|||
self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| &self.image.row(row).view.document)
|
||||
}
|
||||
|
||||
/// The ordinal of the receipt that wrote an image document: its admission order.
|
||||
pub(crate) fn ordinal_of(&self, kind: PipelineKind, id: &str) -> Option<u64> {
|
||||
self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| self.image.row(row).ordinal)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> {
|
||||
self.in_batch(kind, id).or_else(|| self.in_image(kind, id))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use anyhow::Result;
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use cultnet_rs::CultNetMessage;
|
||||
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse};
|
||||
use crate::wire::{HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA, MIND_SERVICE_ID};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -39,17 +40,20 @@ fn encode_payload<T: Serialize>(value: &T) -> Result<String> {
|
|||
Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?))
|
||||
}
|
||||
|
||||
/// One request, addressed to the mind service and naming its own operation.
|
||||
pub fn encode_request(
|
||||
/// One request to `service_id`, naming its own operation, its payload under `payload_schema`.
|
||||
fn encode_operation_request<T: Serialize>(
|
||||
message_id: &str,
|
||||
request: &HuginnMindRequest,
|
||||
service_id: &str,
|
||||
payload_schema: &str,
|
||||
operation: &str,
|
||||
request: &T,
|
||||
source_runtime_id: Option<String>,
|
||||
) -> Result<CultNetMessage> {
|
||||
Ok(CultNetMessage::OperationRequest {
|
||||
message_id: message_id.to_string(),
|
||||
service_id: MIND_SERVICE_ID.to_string(),
|
||||
operation: request.operation().to_string(),
|
||||
payload_schema: MIND_REQUEST_SCHEMA.to_string(),
|
||||
service_id: service_id.to_string(),
|
||||
operation: operation.to_string(),
|
||||
payload_schema: payload_schema.to_string(),
|
||||
payload_encoding: "messagepack-base64".to_string(),
|
||||
payload: encode_payload(request)?,
|
||||
source_runtime_id,
|
||||
|
|
@ -59,43 +63,49 @@ pub fn encode_request(
|
|||
|
||||
/// The message, or the failure that says why it is not a request of this
|
||||
/// service. The envelope's `operation` string is checked against the decoded
|
||||
/// request rather than trusted, so a payload cannot arrive under another
|
||||
/// operation's name.
|
||||
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
|
||||
let CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, payload, .. } = message
|
||||
/// request (`operation_of`) rather than trusted, so a payload cannot arrive
|
||||
/// under another operation's name.
|
||||
fn decode_operation_request<T: serde::de::DeserializeOwned>(
|
||||
message: &CultNetMessage,
|
||||
service_id: &str,
|
||||
payload_schema: &str,
|
||||
operation_of: impl Fn(&T) -> &'static str,
|
||||
) -> Result<(String, T), OperationFailure> {
|
||||
let CultNetMessage::OperationRequest { message_id, service_id: sent_service, operation, payload_schema: sent_schema, payload, .. } = message
|
||||
else {
|
||||
return Err(OperationFailure::new(
|
||||
"not-an-operation-request",
|
||||
"huginn.mind reads cultnet.operation_request.v0",
|
||||
format!("{service_id} reads cultnet.operation_request.v0"),
|
||||
));
|
||||
};
|
||||
if service_id != MIND_SERVICE_ID {
|
||||
return Err(OperationFailure::new("wrong-service", format!("the service is not {MIND_SERVICE_ID}")));
|
||||
if sent_service != service_id {
|
||||
return Err(OperationFailure::new("wrong-service", format!("the service is not {service_id}")));
|
||||
}
|
||||
if payload_schema != MIND_REQUEST_SCHEMA {
|
||||
if sent_schema != payload_schema {
|
||||
return Err(OperationFailure::new(
|
||||
"wrong-payload-schema",
|
||||
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
|
||||
format!("the payload schema is not {payload_schema}"),
|
||||
));
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|_| OperationFailure::new("payload-not-base64", "the payload is not standard base64"))?;
|
||||
let request = read_request(&bytes)?;
|
||||
if operation != request.operation() {
|
||||
let request = read_payload::<T>(&bytes)?;
|
||||
let expected = operation_of(&request);
|
||||
if operation != expected {
|
||||
return Err(OperationFailure::new(
|
||||
"operation-mismatch",
|
||||
format!("the envelope's operation is not the payload's {}", request.operation()),
|
||||
format!("the envelope's operation is not the payload's {expected}"),
|
||||
));
|
||||
}
|
||||
Ok((message_id.clone(), request))
|
||||
}
|
||||
|
||||
/// The payload as a request, or a failure naming the field path and the class
|
||||
/// The payload as a `T`, or a failure naming the field path and the class
|
||||
/// of error and nothing the caller sent: a decoder's own text quotes the
|
||||
/// offending value (an unknown variant, an integer), so no decoder text
|
||||
/// leaves this function.
|
||||
fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
|
||||
fn read_payload<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, OperationFailure> {
|
||||
use rmp_serde::decode::Error as Decode;
|
||||
let mut decoder = rmp_serde::Deserializer::new(bytes);
|
||||
serde_path_to_error::deserialize(&mut decoder).map_err(|error| {
|
||||
|
|
@ -114,19 +124,22 @@ fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
|
|||
})
|
||||
}
|
||||
|
||||
/// One answer, whose status the response derives.
|
||||
pub fn encode_response(
|
||||
/// One answer, with the status its caller derived.
|
||||
fn encode_operation_response<T: Serialize>(
|
||||
message_id: &str,
|
||||
service_id: &str,
|
||||
payload_schema: &str,
|
||||
operation: &str,
|
||||
response: &HuginnMindResponse,
|
||||
status: &str,
|
||||
response: &T,
|
||||
source_runtime_id: &str,
|
||||
) -> Result<CultNetMessage> {
|
||||
Ok(CultNetMessage::OperationResponse {
|
||||
message_id: message_id.to_string(),
|
||||
service_id: MIND_SERVICE_ID.to_string(),
|
||||
service_id: service_id.to_string(),
|
||||
operation: operation.to_string(),
|
||||
status: response.status().to_string(),
|
||||
payload_schema: MIND_RESPONSE_SCHEMA.to_string(),
|
||||
status: status.to_string(),
|
||||
payload_schema: payload_schema.to_string(),
|
||||
payload_encoding: "messagepack-base64".to_string(),
|
||||
payload: encode_payload(response)?,
|
||||
diagnostics: vec![],
|
||||
|
|
@ -136,15 +149,16 @@ pub fn encode_response(
|
|||
|
||||
/// The answer to an envelope that never reached a mind. Infallible: the
|
||||
/// failure is two strings and the daemon must always be able to say no.
|
||||
pub fn encode_failure(
|
||||
fn encode_operation_failure(
|
||||
message_id: &str,
|
||||
service_id: &str,
|
||||
operation: &str,
|
||||
failure: &OperationFailure,
|
||||
source_runtime_id: &str,
|
||||
) -> CultNetMessage {
|
||||
CultNetMessage::OperationResponse {
|
||||
message_id: message_id.to_string(),
|
||||
service_id: MIND_SERVICE_ID.to_string(),
|
||||
service_id: service_id.to_string(),
|
||||
operation: operation.to_string(),
|
||||
status: "rejected".to_string(),
|
||||
payload_schema: FAILURE_SCHEMA.to_string(),
|
||||
|
|
@ -155,21 +169,78 @@ pub fn encode_failure(
|
|||
}
|
||||
}
|
||||
|
||||
/// The client's side: the correlation key and either the mind's answer or the
|
||||
/// failure that says the envelope never reached one.
|
||||
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
|
||||
/// The client's side: the correlation key and either the service's answer or
|
||||
/// the failure that says the envelope never reached one.
|
||||
fn decode_operation_response<T: serde::de::DeserializeOwned>(
|
||||
message: &CultNetMessage,
|
||||
service_id: &str,
|
||||
response_schema: &str,
|
||||
) -> Result<(String, Result<T, OperationFailure>)> {
|
||||
let CultNetMessage::OperationResponse { message_id, payload_schema, payload, .. } = message else {
|
||||
anyhow::bail!("huginn.mind answers with cultnet.operation_response.v0");
|
||||
anyhow::bail!("{service_id} answers with cultnet.operation_response.v0");
|
||||
};
|
||||
let bytes = STANDARD.decode(payload)?;
|
||||
let answer = match payload_schema.as_str() {
|
||||
MIND_RESPONSE_SCHEMA => Ok(rmp_serde::from_slice::<HuginnMindResponse>(&bytes)?),
|
||||
FAILURE_SCHEMA => Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?),
|
||||
other => anyhow::bail!("{other} is neither {MIND_RESPONSE_SCHEMA} nor {FAILURE_SCHEMA}"),
|
||||
let answer = if payload_schema == response_schema {
|
||||
Ok(rmp_serde::from_slice::<T>(&bytes)?)
|
||||
} else if payload_schema == FAILURE_SCHEMA {
|
||||
Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?)
|
||||
} else {
|
||||
anyhow::bail!("{payload_schema} is neither {response_schema} nor {FAILURE_SCHEMA}")
|
||||
};
|
||||
Ok((message_id.clone(), answer))
|
||||
}
|
||||
|
||||
/// One request, addressed to the mind service.
|
||||
pub fn encode_request(
|
||||
message_id: &str,
|
||||
request: &HuginnMindRequest,
|
||||
source_runtime_id: Option<String>,
|
||||
) -> Result<CultNetMessage> {
|
||||
encode_operation_request(message_id, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
|
||||
decode_operation_request(message, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, HuginnMindRequest::operation)
|
||||
}
|
||||
|
||||
pub fn encode_response(
|
||||
message_id: &str,
|
||||
operation: &str,
|
||||
response: &HuginnMindResponse,
|
||||
source_runtime_id: &str,
|
||||
) -> Result<CultNetMessage> {
|
||||
encode_operation_response(message_id, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn encode_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
|
||||
encode_operation_failure(message_id, MIND_SERVICE_ID, operation, failure, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
|
||||
decode_operation_response(message, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA)
|
||||
}
|
||||
|
||||
/// One request, addressed to the desk service.
|
||||
pub fn encode_desk_request(message_id: &str, request: &DeskRequest, source_runtime_id: Option<String>) -> Result<CultNetMessage> {
|
||||
encode_operation_request(message_id, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn decode_desk_request(message: &CultNetMessage) -> Result<(String, DeskRequest), OperationFailure> {
|
||||
decode_operation_request(message, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, DeskRequest::operation)
|
||||
}
|
||||
|
||||
pub fn encode_desk_response(message_id: &str, operation: &str, response: &DeskResponse, source_runtime_id: &str) -> Result<CultNetMessage> {
|
||||
encode_operation_response(message_id, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn encode_desk_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
|
||||
encode_operation_failure(message_id, DESK_SERVICE_ID, operation, failure, source_runtime_id)
|
||||
}
|
||||
|
||||
pub fn decode_desk_response(message: &CultNetMessage) -> Result<(String, Result<DeskResponse, OperationFailure>)> {
|
||||
decode_operation_response(message, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -324,4 +395,284 @@ mod tests {
|
|||
let foreign = CultNetMessage::Error { error: "not for you".into(), code: None, details: None };
|
||||
assert_eq!(decode_request(&foreign).unwrap_err().code, "not-an-operation-request");
|
||||
}
|
||||
|
||||
// ---- the desk's instantiation of the same cores ----
|
||||
|
||||
use crate::desk_wire::{
|
||||
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse,
|
||||
ForkAnswer, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode, RefusalSchema,
|
||||
};
|
||||
use cultnet_rs::{CultNetWireContract, encode_cultnet_message_to_vec};
|
||||
use eureka_pipeline::DeskAuthority;
|
||||
|
||||
fn desk_answer() -> DeskRequest {
|
||||
DeskRequest::Answer {
|
||||
mind: slug(INSTANCE),
|
||||
subject: crate::fixtures::s("user-7"),
|
||||
authority: DeskAuthority::Member,
|
||||
deciders: vec![crate::fixtures::s("user-7")],
|
||||
request: ForkAnswerRequest {
|
||||
schema_version: crate::desk_wire::RequestSchema::V0,
|
||||
fork_id: "eureka-state:question:Q1".into(),
|
||||
options_hash: "0".repeat(64),
|
||||
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
|
||||
retracted: false,
|
||||
retracts: None,
|
||||
answered_at: "2026-11-10T12:00:00Z".into(),
|
||||
request_id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn desk_requests() -> [(DeskRequest, &'static str); 3] {
|
||||
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]);
|
||||
[
|
||||
(desk_answer(), "Answer"),
|
||||
(DeskRequest::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone(), status: Some(crate::desk_wire::ListStatus::Open), cursor: crate::desk_wire::ForkCursor::new(7), limit: crate::desk_wire::ForkLimit::new(50).unwrap() }, "Forks"),
|
||||
(DeskRequest::Fork { mind, subject, deciders, fork_id: "eureka-state:question:Q1".into() }, "Fork"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The fields of a desk request ARE Libby's wire names, with no mapping layer between: `mind`,
|
||||
/// `subject`, `authority`, `deciders`, `request`. The persona and pipeline requests keep
|
||||
/// `instance`; the sets do not mix. A key outside the set, or one that would make the caller
|
||||
/// its own authority (`answerer`, `attribution`), refuses the whole payload.
|
||||
#[test]
|
||||
fn desk_request_fields_are_the_wire_names_and_the_set_is_closed() {
|
||||
let keys = |value: &serde_json::Value, variant: &str| {
|
||||
let mut keys: Vec<String> = value[variant].as_object().unwrap().keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
let expected = [
|
||||
("Answer", vec!["authority", "deciders", "mind", "request", "subject"]),
|
||||
("Forks", vec!["cursor", "deciders", "limit", "mind", "status", "subject"]),
|
||||
("Fork", vec!["deciders", "fork_id", "mind", "subject"]),
|
||||
];
|
||||
for ((request, variant), (named, names)) in desk_requests().iter().zip(expected) {
|
||||
assert_eq!(*variant, named);
|
||||
let value = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(keys(&value, variant), names, "{variant}");
|
||||
let message = encode_desk_request("m-1", request, None).unwrap();
|
||||
assert_eq!(decode_desk_request(&message).unwrap(), ("m-1".into(), request.clone()));
|
||||
for extra in ["answerer", "attribution", "instance", "mood"] {
|
||||
let mut bad = value.clone();
|
||||
bad[*variant].as_object_mut().unwrap().insert(extra.into(), serde_json::json!(CANARY_KEY));
|
||||
let payload = STANDARD.encode(rmp_serde::to_vec_named(&bad).unwrap());
|
||||
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
|
||||
let failure = decode_desk_request(&message).unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request", "{variant} with {extra}");
|
||||
assert!(failure.message.contains(variant), "{variant} with {extra}: {}", failure.message);
|
||||
assert!(!failure.message.contains(CANARY_KEY), "{}", failure.message);
|
||||
}
|
||||
// `mind` is the desk's word: renamed to `instance` the payload is no desk request.
|
||||
let mut renamed = value.clone();
|
||||
let moved = renamed[*variant].as_object_mut().unwrap().remove("mind").unwrap();
|
||||
renamed[*variant].as_object_mut().unwrap().insert("instance".into(), moved);
|
||||
let payload = STANDARD.encode(rmp_serde::to_vec_named(&renamed).unwrap());
|
||||
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
|
||||
assert_eq!(decode_desk_request(&message).unwrap_err().code, "payload-not-a-request");
|
||||
}
|
||||
// And the other way: the mind's `instance` named `mind` is no persona request.
|
||||
let put = HuginnMindRequest::PersonaPut {
|
||||
instance: slug(INSTANCE),
|
||||
provenance: provenance(Faculty::Hands),
|
||||
state: serde_json::json!({}),
|
||||
expected_updated_at: None,
|
||||
};
|
||||
let mut value = serde_json::to_value(&put).unwrap();
|
||||
let moved = value["PersonaPut"].as_object_mut().unwrap().remove("instance").unwrap();
|
||||
value["PersonaPut"].as_object_mut().unwrap().insert("mind".into(), moved);
|
||||
let message = request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()));
|
||||
assert_eq!(decode_request(&message).unwrap_err().code, "payload-not-a-request");
|
||||
}
|
||||
|
||||
const CANARY_KEY: &str = "CANARY-9e41-do-not-echo";
|
||||
|
||||
/// Two services share the cores and nothing else: a payload under the other service's schema,
|
||||
/// service id or operation name never reaches a decoder.
|
||||
#[test]
|
||||
fn desk_and_mind_payloads_do_not_cross() {
|
||||
let whoami = encode_request("m-5", &HuginnMindRequest::Whoami, None).unwrap();
|
||||
let CultNetMessage::OperationRequest { payload: mind_payload, .. } = &whoami else { panic!("not a request") };
|
||||
for (request, operation) in desk_requests().iter().map(|(request, _)| (request, request.operation())) {
|
||||
let CultNetMessage::OperationRequest { payload: desk_payload, .. } = encode_desk_request("m-6", request, None).unwrap() else { panic!("not a request") };
|
||||
// A desk payload under the mind's schema and a mind payload under the desk's.
|
||||
let under_mind = request_message(DESK_SERVICE_ID, operation, MIND_REQUEST_SCHEMA, desk_payload.clone());
|
||||
assert_eq!(decode_desk_request(&under_mind).unwrap_err().code, "wrong-payload-schema");
|
||||
let under_desk = request_message(MIND_SERVICE_ID, "whoami", DESK_REQUEST_SCHEMA, mind_payload.clone());
|
||||
assert_eq!(decode_request(&under_desk).unwrap_err().code, "wrong-payload-schema");
|
||||
// The mind's service id at the desk decoder, and the desk's at the mind's.
|
||||
let mind_service = request_message(MIND_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, desk_payload.clone());
|
||||
assert_eq!(decode_desk_request(&mind_service).unwrap_err().code, "wrong-service");
|
||||
let desk_service = request_message(DESK_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA, mind_payload.clone());
|
||||
assert_eq!(decode_request(&desk_service).unwrap_err().code, "wrong-service");
|
||||
// The right labels around the other service's bytes: not a request.
|
||||
let wrong_bytes = request_message(DESK_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, mind_payload.clone());
|
||||
assert_eq!(decode_desk_request(&wrong_bytes).unwrap_err().code, "payload-not-a-request");
|
||||
// An operation name that is not the payload's.
|
||||
let other = if operation == "forks" { "fork" } else { "forks" };
|
||||
let mismatch = request_message(DESK_SERVICE_ID, other, DESK_REQUEST_SCHEMA, desk_payload);
|
||||
assert_eq!(decode_desk_request(&mismatch).unwrap_err().code, "operation-mismatch");
|
||||
}
|
||||
// The three operations name themselves, on the envelope too.
|
||||
let named: Vec<String> = desk_requests()
|
||||
.iter()
|
||||
.map(|(request, _)| match encode_desk_request("m", request, None).unwrap() {
|
||||
CultNetMessage::OperationRequest { service_id, operation, payload_schema, .. } => {
|
||||
assert_eq!((service_id.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, DESK_REQUEST_SCHEMA));
|
||||
operation
|
||||
}
|
||||
_ => panic!("not a request"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(named, ["answer", "forks", "fork"]);
|
||||
}
|
||||
|
||||
/// A desk answer is an answer on the desk's response schema; a refusal that stored nothing and
|
||||
/// any other Mind refusal ride `rejected`; a failure rides the shared failure schema under the
|
||||
/// desk's service id.
|
||||
#[test]
|
||||
fn a_desk_response_round_trips_with_its_status() {
|
||||
let answered = DeskResponse::Answered {
|
||||
answer: ForkDeskAnswer {
|
||||
schema_version: crate::desk_wire::AnswerSchema::V0,
|
||||
answer_id: "n1".into(),
|
||||
fork_id: "eureka-state:question:Q1".into(),
|
||||
outcome: "Counted".into(),
|
||||
of: None,
|
||||
retracts: None,
|
||||
code: None,
|
||||
options_hash: "0".repeat(64),
|
||||
choice: Some("A".into()),
|
||||
note: None,
|
||||
answered_at: "2026-11-10T12:00:00Z".into(),
|
||||
received_at: "2026-11-10T12:00:01Z".into(),
|
||||
claim_in_future: false,
|
||||
answerer: "user-7".into(),
|
||||
authority: DeskAuthority::Member,
|
||||
request_id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
},
|
||||
counted_answer_id: Some("n1".into()),
|
||||
as_of: 9,
|
||||
};
|
||||
let not_stored = DeskResponse::NotStored(ForkRefusal { schema_version: RefusalSchema::V0, code: ForkRefusalCode::ForkUnknown, error: "fork_unknown".into() });
|
||||
for (response, status) in [(answered, "accepted"), (not_stored, "rejected"), (DeskResponse::Refused(refusal()), "rejected")] {
|
||||
let message = encode_desk_response("m-7", "answer", &response, "huginn-yggdrasil").unwrap();
|
||||
let CultNetMessage::OperationResponse { service_id, status: sent, payload_schema, .. } = &message else { panic!("not a response") };
|
||||
assert_eq!((service_id.as_str(), sent.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, status, DESK_RESPONSE_SCHEMA));
|
||||
assert_eq!(decode_desk_response(&message).unwrap(), ("m-7".into(), Ok(response)));
|
||||
}
|
||||
let failure = OperationFailure::new("wrong-service", "the service is not huginn.desk");
|
||||
let message = encode_desk_failure("m-8", "forks", &failure, "huginn-yggdrasil");
|
||||
let CultNetMessage::OperationResponse { service_id, status, payload_schema, .. } = &message else { panic!("not a response") };
|
||||
assert_eq!((service_id.as_str(), status.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, "rejected", FAILURE_SCHEMA));
|
||||
assert_eq!(decode_desk_response(&message).unwrap(), ("m-8".into(), Err(failure)));
|
||||
// The mind's response schema is not the desk's.
|
||||
let mind = encode_response("m-9", "whoami", &HuginnMindResponse::Refused(refusal()), "huginn-yggdrasil").unwrap();
|
||||
assert!(decode_desk_response(&mind).is_err());
|
||||
}
|
||||
|
||||
/// The largest message either side can build fits the frame the door enforces: 64 deciders of
|
||||
/// 200 bytes and a 4000-byte note in a request, the widest stored answer in a response.
|
||||
#[test]
|
||||
fn a_maximal_desk_message_fits_the_frame() {
|
||||
let long = |n: usize| "x".repeat(n);
|
||||
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len();
|
||||
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::s(&format!("{at:0>200}"))).collect();
|
||||
let mut sized = vec![];
|
||||
for (request, _) in desk_requests() {
|
||||
let widest = match request {
|
||||
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
|
||||
mind,
|
||||
subject: crate::fixtures::s(&long(200)),
|
||||
authority,
|
||||
deciders: deciders.clone(),
|
||||
request: ForkAnswerRequest {
|
||||
fork_id: long(200),
|
||||
answer: Some(ForkAnswer { choice: Some(long(200)), note: Some(long(4000)) }),
|
||||
retracts: Some("n4294967295".into()),
|
||||
..request
|
||||
},
|
||||
},
|
||||
DeskRequest::Forks { mind, status, cursor, limit, .. } => {
|
||||
DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit }
|
||||
}
|
||||
DeskRequest::Fork { mind, .. } => {
|
||||
DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) }
|
||||
}
|
||||
};
|
||||
let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
|
||||
assert_eq!(decode_desk_request(&message).unwrap().1, widest);
|
||||
sized.push(wire(&message));
|
||||
}
|
||||
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}");
|
||||
assert!(sized[0] > DESK_MAX_REQUEST_BYTES / 4, "the test builds a request that is actually large: {sized:?}");
|
||||
|
||||
let answer = ForkDeskAnswer {
|
||||
schema_version: crate::desk_wire::AnswerSchema::V0,
|
||||
answer_id: "n4294967295".into(),
|
||||
fork_id: long(200),
|
||||
outcome: "Refused".into(),
|
||||
of: Some("n4294967295".into()),
|
||||
retracts: Some("n4294967295".into()),
|
||||
code: None,
|
||||
options_hash: "f".repeat(64),
|
||||
choice: Some(long(200)),
|
||||
note: Some(long(4000)),
|
||||
answered_at: "2026-11-10T12:00:00.123456789+00:00".into(),
|
||||
received_at: "2026-11-10T12:00:00.123456789+00:00".into(),
|
||||
claim_in_future: true,
|
||||
answerer: long(200),
|
||||
authority: DeskAuthority::Operator,
|
||||
request_id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
};
|
||||
let response = DeskResponse::Answered { answer, counted_answer_id: Some("n4294967295".into()), as_of: u64::MAX };
|
||||
let message = encode_desk_response("m-11", "answer", &response, &long(200)).unwrap();
|
||||
assert!(wire(&message) <= DESK_MAX_RESPONSE_BYTES && wire(&message) > 4000, "{}", wire(&message));
|
||||
assert_eq!(decode_desk_response(&message).unwrap().1, Ok(response));
|
||||
}
|
||||
|
||||
/// A desk call's refusal rides `huginn.desk_response.v1` whole: code and detail survive the
|
||||
/// wire, and the published schema declares the variant. One row per refusal a desk call can
|
||||
/// return, the four the door maps to `NotStored` included, because the response type can
|
||||
/// carry any of them.
|
||||
#[test]
|
||||
fn every_refusal_a_desk_call_can_return_round_trips_losslessly() {
|
||||
use crate::desk_wire::DESK_RESPONSE_SCHEMA_JSON;
|
||||
use eureka_pipeline::{PipelineKind, PipelineRefusal};
|
||||
let refusals = [
|
||||
MindRefusal::Unavailable { detail: "the store did not answer".into() },
|
||||
MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() },
|
||||
MindRefusal::MissingIdentity,
|
||||
MindRefusal::DeskAnswerOutsideDesk { id: "eureka-state:desk_answer:Q1.n1".into() },
|
||||
MindRefusal::MissingReference { kind: PipelineKind::Question, id: "eureka-state:question:Q9".into() },
|
||||
MindRefusal::CampaignMoved { campaign: "eureka-state".into(), to: OTHER.into() },
|
||||
MindRefusal::DeskRequestInvalid { field: "desk_answer.note".into() },
|
||||
MindRefusal::DeskRequestIdConflict { request_id: "00000000-0000-4000-8000-000000000001".into() },
|
||||
MindRefusal::Document(PipelineRefusal::FieldBound { field: "note".into(), limit: 4000, actual: 4001 }),
|
||||
MindRefusal::Document(PipelineRefusal::InvalidFormat { field: "request_id".into(), value: "x".into() }),
|
||||
MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: PipelineKind::DeskAnswer, key: "k".into(), expected: "e".into() }),
|
||||
MindRefusal::Document(PipelineRefusal::DuplicateRepo { field: "repos".into(), repo: "GameCult/Huginn".into() }),
|
||||
MindRefusal::Document(PipelineRefusal::ForeignStore { r#type: "other".into() }),
|
||||
];
|
||||
let schema: serde_json::Value = serde_json::from_str(DESK_RESPONSE_SCHEMA_JSON).unwrap();
|
||||
assert!(schema["$defs"]["MindRefusal"].is_object() && schema["$defs"]["PipelineRefusal"].is_object());
|
||||
let declared = serde_json::to_string(&schema).unwrap();
|
||||
for refusal in refusals {
|
||||
let response = DeskResponse::Refused(refusal.clone());
|
||||
let message = encode_desk_response("m-12", "answer", &response, "huginn-yggdrasil").unwrap();
|
||||
let (_, decoded) = decode_desk_response(&message).unwrap();
|
||||
assert_eq!(decoded, Ok(DeskResponse::Refused(refusal.clone())), "{refusal:?}");
|
||||
let value = serde_json::to_value(&refusal).unwrap();
|
||||
let variant = value.as_str().map(str::to_string).or_else(|| value.as_object().and_then(|object| object.keys().next().cloned())).unwrap();
|
||||
assert!(declared.contains(&format!("\"{variant}\"")), "the published schema declares {variant}");
|
||||
// Each detail field is declared too, so a validator can hold the payload to the schema.
|
||||
if let Some(object) = value.get(&variant).and_then(|inner| inner.as_object()) {
|
||||
for field in object.keys() {
|
||||
assert!(declared.contains(&format!("\"{field}\"")), "{variant}.{field} is declared");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! how a change to it makes every projection rebuild. No network, no JSON, no
|
||||
//! store handle: the daemon owns the vector store and the embedder.
|
||||
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef};
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, RulingAuthority};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::mind::Mind;
|
||||
|
|
@ -56,6 +56,9 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
|
|||
lines.push(&ruling.title.0);
|
||||
lines.push(&ruling.ruling.0);
|
||||
lines.extend(ruling.operator_quote.iter().map(|quote| quote.0.as_str()));
|
||||
if let RulingAuthority::Steward { words, .. } = &ruling.authority {
|
||||
lines.push(&words.0);
|
||||
}
|
||||
}
|
||||
D::CutSpec(spec) => {
|
||||
lines.push(&spec.title.0);
|
||||
|
|
@ -247,6 +250,15 @@ More documents.");
|
|||
assert_eq!(INDEX_TEXT_VERSION, 1);
|
||||
}
|
||||
|
||||
/// A Steward ruling is found by the words the peer said as well as by its title and ruling.
|
||||
#[test]
|
||||
fn a_steward_rulings_words_are_indexed() {
|
||||
let mut peer = ruling("R1");
|
||||
peer.operator_quote = None;
|
||||
peer.authority = RulingAuthority::Steward { steward: s("@heimdall:chat"), repo: eureka_pipeline::OrgRepo(REPO.into()), words: "refuse leading zeros".into(), event: None };
|
||||
assert!(index_text(&D::Ruling(peer)).unwrap().lines().any(|line| line == "refuse leading zeros"));
|
||||
}
|
||||
|
||||
fn texts(mind: &Mind<MemoryStore>) -> BTreeMap<String, String> {
|
||||
mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ mod read_tests;
|
|||
mod move_tests;
|
||||
|
||||
pub use desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, options_hash};
|
||||
pub use desk_wire::{DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode};
|
||||
pub use desk_wire::{DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode};
|
||||
pub use docs::CitationRole;
|
||||
pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text};
|
||||
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ impl PipelineFacts {
|
|||
title: value.title.clone(),
|
||||
answers: value.answers.clone(),
|
||||
choice: value.choice.clone(),
|
||||
authority: value.authority,
|
||||
authority: value.authority.clone(),
|
||||
ruled_on: value.ruled_on.clone(),
|
||||
},
|
||||
D::CutSpec(value) => Self::CutSpec {
|
||||
|
|
@ -1504,6 +1504,25 @@ mod tests {
|
|||
assert!(matches!(refused(&mind, &misspelt), MindRefusal::SelectionInvalid { value: Some(v), .. } if v == "Minds"));
|
||||
}
|
||||
|
||||
/// A peer's ruling is found by the Mind who decided, by the stewardship she decided under, and by authority.
|
||||
#[test]
|
||||
fn a_peers_rulings_are_selected_by_mind_and_by_repo() {
|
||||
let mut mind = seeded();
|
||||
let mut peer = ruling("R1");
|
||||
peer.authority = RulingAuthority::Steward {
|
||||
steward: "@heimdall:chat".into(),
|
||||
repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()),
|
||||
words: "refuse leading zeros".into(),
|
||||
event: None,
|
||||
};
|
||||
committed(admit(&mut mind, vec![D::Ruling(peer), D::Ruling(ruling("R2"))]));
|
||||
let found = |alias: &str, word: &str| mind.query(&with(of_kinds(&[K::Ruling]), any_of(alias, &[word]))).unwrap().matched;
|
||||
assert_eq!(found("authority", "Steward"), 1);
|
||||
assert_eq!(found("about", "@heimdall:chat"), 1);
|
||||
assert_eq!(found("repo", "GameCult/Bifrost"), 1);
|
||||
assert_eq!(found("repo", "GameCult/CultLib"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_schema_or_malformed_key_is_refused() {
|
||||
let mind = worked();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::collections::BTreeMap;
|
|||
|
||||
use cultnet_rs::{Row, RowSet, Selection};
|
||||
use eureka_pipeline::{
|
||||
FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome,
|
||||
FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRuling, ResolutionOutcome,
|
||||
RulingAuthority, RunOperator, RunTurn, Slug,
|
||||
};
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ impl Alias {
|
|||
match self {
|
||||
Self::Root | Self::InForce | Self::Faculty => true,
|
||||
Self::Repo => {
|
||||
matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution)
|
||||
matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution | K::Ruling)
|
||||
}
|
||||
Self::Cut => matches!(kind, K::CutSpec | K::CutReport | K::Verdict | K::Finding | K::Resolution),
|
||||
Self::Severity | Self::Confidence | Self::Origin => kind == K::Finding,
|
||||
|
|
@ -143,7 +143,8 @@ impl Alias {
|
|||
Self::Outcome => kind == K::Resolution,
|
||||
Self::Turn | Self::OperatedBy | Self::Ran => kind == K::Run,
|
||||
Self::Voted | Self::Urged => kind == K::Pulse,
|
||||
Self::Sort | Self::About | Self::MemoryOrigin => kind == K::Memory,
|
||||
Self::Sort | Self::MemoryOrigin => kind == K::Memory,
|
||||
Self::About => matches!(kind, K::Memory | K::Ruling),
|
||||
Self::Pass => matches!(kind, K::Research | K::Idea),
|
||||
}
|
||||
}
|
||||
|
|
@ -246,6 +247,7 @@ impl SelectionRow {
|
|||
D::FollowUp(follow_up) => vec![follow_up.repo.identity()],
|
||||
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
|
||||
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
|
||||
D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![repo.identity()],
|
||||
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => {
|
||||
Vec::new()
|
||||
}
|
||||
|
|
@ -267,7 +269,12 @@ impl SelectionRow {
|
|||
put(Alias::Confidence, vec![format!("{:?}", finding.confidence)]);
|
||||
put(Alias::Origin, vec![format!("{:?}", finding.origin)]);
|
||||
}
|
||||
D::Ruling(ruling) => put(Alias::Authority, vec![format!("{:?}", ruling.authority)]),
|
||||
D::Ruling(ruling) => {
|
||||
put(Alias::Authority, vec![ruling.authority.name().to_string()]);
|
||||
if let RulingAuthority::Steward { steward, .. } = &ruling.authority {
|
||||
put(Alias::About, vec![steward.0.clone()]);
|
||||
}
|
||||
}
|
||||
D::Verdict(verdict) => {
|
||||
put(Alias::ClaimOutcome, verdict.claims.iter().map(|claim| format!("{:?}", claim.outcome)).collect())
|
||||
}
|
||||
|
|
@ -463,7 +470,7 @@ mod tests {
|
|||
(K::Campaign, &["repo"]),
|
||||
(K::Target, &[]),
|
||||
(K::Question, &[]),
|
||||
(K::Ruling, &["authority"]),
|
||||
(K::Ruling, &["authority", "repo", "about"]),
|
||||
(K::CutSpec, &["repo", "cut"]),
|
||||
(K::CutReport, &["repo", "cut"]),
|
||||
(K::Verdict, &["cut", "claim_outcome"]),
|
||||
|
|
|
|||
|
|
@ -581,4 +581,27 @@ mod tests {
|
|||
assert!(request.contains(&format!("\"$ref\": \"{selection}\"")));
|
||||
assert!(!request.contains("\"cursor\""), "no second copy of the selection's shape");
|
||||
}
|
||||
|
||||
/// The desk's two published files are the derivation of its two types, byte for byte, and
|
||||
/// the catalog index lists both as wire messages beside the mind's.
|
||||
#[test]
|
||||
fn published_desk_schemas_match_derivation() {
|
||||
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA, DESK_RESPONSE_SCHEMA_JSON, DeskRequest, DeskResponse};
|
||||
let request = serde_json::to_string_pretty(&schemars::schema_for!(DeskRequest)).unwrap();
|
||||
let response = serde_json::to_string_pretty(&schemars::schema_for!(DeskResponse)).unwrap();
|
||||
assert_eq!(DESK_REQUEST_SCHEMA_JSON.replace("\r\n", "\n"), request);
|
||||
assert_eq!(DESK_RESPONSE_SCHEMA_JSON.replace("\r\n", "\n"), response);
|
||||
// The deciders bound is in the published schema, not only in the decoder.
|
||||
assert!(request.contains("\"maxItems\": 64") && request.contains("\"uniqueItems\": true") && request.contains("\"minItems\": 1"), "deciders bound");
|
||||
let index: serde_json::Value = serde_json::from_str(include_str!("../../../schemas/cultnet/index.json")).unwrap();
|
||||
for (id, title) in [(DESK_REQUEST_SCHEMA, "Huginn desk request"), (DESK_RESPONSE_SCHEMA, "Huginn desk response")] {
|
||||
let rows: Vec<_> = index["schemas"].as_array().unwrap().iter().filter(|row| row["schemaId"] == id).collect();
|
||||
assert_eq!(rows.len(), 1, "{id}");
|
||||
let row = rows[0];
|
||||
assert_eq!(
|
||||
(row["kind"].as_str(), row["schemaVersion"].as_str(), row["title"].as_str(), row["path"].as_str(), row["wireContracts"][0].as_str()),
|
||||
(Some("wire_message"), Some(id), Some(title), Some(format!("{id}.schema.json").as_str()), Some("cultnet.schema.v0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -25,7 +25,8 @@ publish.workspace = true
|
|||
#
|
||||
# The stream organ (`stream`, the `mind-stream` binary) builds its Projector prompt with Ghostlight's
|
||||
# `ghostlight-persona-projection`, pinned to a revision, so the membrane's text stays Ghostlight's; it
|
||||
# speaks to a model host on loopback over `ureq` 3 with default features off (plain HTTP, no TLS), as the
|
||||
# asks its Projector through the connector kit's client (`model-connector`, one turn per tick) and speaks to the base
|
||||
# model's host on loopback over `ureq` 3 with default features off (plain HTTP, no TLS), as the
|
||||
# daemon's embedder does, and stays below 3.4 for the same reason.
|
||||
#
|
||||
# `serde_json` reads Claude Code's stream-json, a foreign owner's wire (`self_run`);
|
||||
|
|
@ -63,6 +64,9 @@ proc-macro2 = "1"
|
|||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
tempfile = "3"
|
||||
# A real kit server (`serve` over a scripted `ProviderBackend`) proves the stream's Projector wire, not a fake of it.
|
||||
# Same revision as the dependency; the `daemon` feature is test-only.
|
||||
model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector", features = ["daemon"] }
|
||||
|
||||
[lib]
|
||||
name = "mind_body"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use eureka_pipeline::{OrgRepo, Short};
|
||||
|
||||
use crate::chat_client::ChatDelivery;
|
||||
use crate::chat_client::{Basis, ChatDelivery, Form};
|
||||
|
||||
/// A word a message may call a Mind or her project by.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -16,25 +16,34 @@ pub struct Name {
|
|||
pub(crate) exact: bool,
|
||||
}
|
||||
|
||||
/// Whom a delivery may be addressing: one Mind's Matrix id and her names.
|
||||
/// Whom a delivery may be addressing: one Mind's Matrix id, her names and the repos she stewards.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Addressee {
|
||||
matrix_id: String,
|
||||
names: Vec<Name>,
|
||||
/// The repos her in-force stewardships name, as the one read that gave `names` found them: what a ruling of hers may be decided under.
|
||||
#[serde(default)]
|
||||
repos: Vec<OrgRepo>,
|
||||
}
|
||||
|
||||
impl Addressee {
|
||||
/// A Mind by her id and her own names, each matched in any case.
|
||||
pub(crate) fn new<'a>(matrix_id: &str, own_names: impl IntoIterator<Item = &'a str>) -> Self {
|
||||
Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect() }
|
||||
Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect(), repos: Vec::new() }
|
||||
}
|
||||
|
||||
/// The same Mind, also addressed by her projects' names.
|
||||
pub(crate) fn with_projects(mut self, names: impl IntoIterator<Item = Name>) -> Self {
|
||||
/// The same Mind, also addressed by her projects' names, with the repos those stewardships name.
|
||||
pub(crate) fn with_stewardships(mut self, names: impl IntoIterator<Item = Name>, repos: impl IntoIterator<Item = OrgRepo>) -> Self {
|
||||
self.names.extend(names);
|
||||
self.repos.extend(repos);
|
||||
self
|
||||
}
|
||||
|
||||
/// The repos she stewards, as the heartbeat last read them.
|
||||
pub(crate) fn repos(&self) -> &[OrgRepo] {
|
||||
&self.repos
|
||||
}
|
||||
|
||||
pub(crate) fn matrix_id(&self) -> &str {
|
||||
&self.matrix_id
|
||||
}
|
||||
|
|
@ -46,9 +55,17 @@ pub(crate) fn localpart(matrix_id: &str) -> &str {
|
|||
id.split_once(':').map_or(id, |(local, _)| local)
|
||||
}
|
||||
|
||||
/// Whether a delivery is for `who`: tagged, or named by a word of hers.
|
||||
/// Whether a delivery is for `who` by what it says: only a message can be (a notice or an edit never is, whatever else it says), and
|
||||
/// then a tag of hers in its mentions or a word of hers in the text. What Matrix says about the Persona that polled (`Basis`) is not
|
||||
/// here: it is a fact about that one Persona, not about every Mind in the table (`is_for`).
|
||||
pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool {
|
||||
delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact))
|
||||
delivery.form == Form::Message && (delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact)))
|
||||
}
|
||||
|
||||
/// Whether a delivery the mouth gave `own` is for her: her DM (`is_direct`, which every mouth sends, so an old mouth's DM is hers too),
|
||||
/// Matrix's word that it is a tag of hers or a reply to her, or what it says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide.
|
||||
pub(crate) fn is_for(own: &Addressee, delivery: &ChatDelivery) -> bool {
|
||||
(delivery.form == Form::Message && (delivery.is_direct || matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply)))) || addresses(own, delivery)
|
||||
}
|
||||
|
||||
/// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived one-word repo name as the repo spells it, a split phrase or a Mind's own in any case.
|
||||
|
||||
use super::*;
|
||||
use crate::chat_client::{Basis, Form};
|
||||
|
||||
fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
|
||||
ChatDelivery {
|
||||
|
|
@ -10,6 +11,8 @@ fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
|
|||
room_alias: "swarm".into(),
|
||||
is_direct: false,
|
||||
image: None,
|
||||
basis: None,
|
||||
form: Form::Message,
|
||||
sender: "@op:chat".into(),
|
||||
sender_display: "Op".into(),
|
||||
at_unix_ms: 0,
|
||||
|
|
@ -24,7 +27,7 @@ fn names(texts: &[&str]) -> Vec<Short> {
|
|||
}
|
||||
|
||||
fn steward(repo: &str, listed: &[&str]) -> Addressee {
|
||||
Addressee::new("@nibu:chat", ["Nibu"]).with_projects(project_names(&OrgRepo(repo.into()), &names(listed)))
|
||||
Addressee::new("@nibu:chat", ["Nibu"]).with_stewardships(project_names(&OrgRepo(repo.into()), &names(listed)), [OrgRepo(repo.into())])
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -61,7 +64,7 @@ fn project_names_derives_the_repo_name_and_its_split_form_and_listed_names_repla
|
|||
|
||||
#[test]
|
||||
fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_and_a_split_phrase_in_any_case() {
|
||||
let libby = Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"])));
|
||||
let libby = Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"])), [OrgRepo("GameCult/Thing".into())]);
|
||||
assert!(addresses(&libby, &said("Thing is down", &[])));
|
||||
assert!(!addresses(&libby, &said("one thing at a time", &[])));
|
||||
assert!(!addresses(&libby, &said("THING", &[])));
|
||||
|
|
@ -84,11 +87,11 @@ fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_a
|
|||
#[test]
|
||||
fn one_name_addresses_every_mind_that_lists_it_each_on_her_own_record() {
|
||||
let lore = steward("GameCult/AetheriaLore", &[]);
|
||||
let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_projects(project_names(&OrgRepo("GameCult/Aetheria".into()), &[]));
|
||||
let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_stewardships(project_names(&OrgRepo("GameCult/Aetheria".into()), &[]), [OrgRepo("GameCult/Aetheria".into())]);
|
||||
let both = said("Aetheria lore needs a pass", &[]);
|
||||
assert!(addresses(&aetheria, &both), "a name inside another Mind's longer name addresses her too");
|
||||
assert!(addresses(&lore, &both));
|
||||
let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &[]))];
|
||||
let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &[]), [OrgRepo("GameCult/Thing".into())])];
|
||||
assert!(two_stewards.iter().all(|who| addresses(who, &said("Thing is broken", &[]))), "a project with two stewards addresses each");
|
||||
assert!(!addresses(&two_stewards[0], &said("a thing", &[])), "listed Thing is exact");
|
||||
assert!(!addresses(&two_stewards[1], &said("a thing", &[])), "derived Thing is exact as the repo spells it");
|
||||
|
|
@ -108,3 +111,29 @@ fn the_localpart_is_the_name_between_the_at_and_the_colon() {
|
|||
assert_eq!(localpart("@eureka:chat.gamecult.org"), "eureka");
|
||||
assert_eq!(localpart("eureka"), "eureka");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_message_can_address_and_the_mouths_basis_stands_for_the_polling_mind_alone() {
|
||||
let nibu = steward("GameCult/Thing", &[]);
|
||||
let with = |basis, form, text: &str, mentions: &[&str]| ChatDelivery { basis, form, ..said(text, mentions) };
|
||||
for basis in [Basis::Direct, Basis::Tag, Basis::Reply] {
|
||||
assert!(is_for(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "{basis:?}");
|
||||
assert!(!addresses(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "a basis is a fact about the Persona that polled, not about every Mind: {basis:?}");
|
||||
assert!(!is_for(&nibu, &with(Some(basis), Form::Notice, "nothing named", &[])), "a notice is for no one: {basis:?}");
|
||||
assert!(!is_for(&nibu, &with(Some(basis), Form::Edit, "nibu, look", &["@nibu:chat"])), "nor an edit, tagged and named: {basis:?}");
|
||||
assert!(!addresses(&nibu, &with(None, Form::Edit, "nibu, look", &["@nibu:chat"])), "{basis:?}");
|
||||
}
|
||||
assert!(!is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nothing named", &[])), "@room is for no one");
|
||||
assert!(!is_for(&nibu, &with(None, Form::Message, "nothing named", &[])), "unproven and unnamed");
|
||||
assert!(is_for(&nibu, &with(None, Form::Message, "nibu, look", &[])), "unproven is not 'not for her': her name decides");
|
||||
assert!(is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nibu, look", &[])), "an @room that also names her is named");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_message_is_for_her_whatever_the_basis() {
|
||||
let nibu = steward("GameCult/Thing", &[]);
|
||||
let dm = ChatDelivery { is_direct: true, ..said("nothing named", &[]) };
|
||||
assert!(is_for(&nibu, &dm), "an old mouth sends no basis; the DM is hers");
|
||||
assert!(!is_for(&nibu, &ChatDelivery { form: Form::Notice, ..dm.clone() }));
|
||||
assert!(!is_for(&nibu, &ChatDelivery { form: Form::Edit, ..dm }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@
|
|||
//! the library never does.
|
||||
//!
|
||||
//! Environment: `HUGINN_ENDPOINT` (`rudp://<ip>:<port>`, her own daemon), `EUREKA_INSTANCE`
|
||||
//! (when `--instance` is not given), `MIND_STREAM_ENDPOINT` (`http://<loopback>:<port>`, the
|
||||
//! model host), `MIND_STREAM_PROJECTOR_MODEL` and `MIND_STREAM_BASE_MODEL`.
|
||||
//! (when `--instance` is not given), `MIND_STREAM_CONNECTOR_ENDPOINT` (`<loopback ip>:<port>`, the
|
||||
//! connector kit the Projector is asked through), `MIND_STREAM_PROJECTOR_MODEL` (the model id the
|
||||
//! kit's caller row allows), `MIND_STREAM_ENDPOINT` (`http://<loopback>:<port>`, the model host) and
|
||||
//! `MIND_STREAM_BASE_MODEL`. The connector key is the file `connector-key` in `CREDENTIALS_DIRECTORY`
|
||||
//! (systemd's `LoadCredential`), read once; the caller id is the instance.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -20,15 +24,23 @@ use eureka_pipeline::Slug;
|
|||
use eureka_state::HuginnClient;
|
||||
use mind_body::control::read_effective;
|
||||
use mind_body::queue::HuginnMind;
|
||||
use mind_body::stream::{HuginnPersonaSource, Mode, OllamaStream, Organs, Outcome, STREAM_MIN_GAP_S, STREAM_POLL_S, tick};
|
||||
use mind_body::stream::{HuginnPersonaSource, KitProjector, Mode, OllamaContinuer, Organs, Outcome, STREAM_MIN_GAP_S, STREAM_POLL_S, connector_key, tick};
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long the kit may take to answer one Projector turn.
|
||||
const PROJECTOR_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[allow(clippy::disallowed_methods, reason = "the binary reads its own environment, and hands it to the library")]
|
||||
fn setting(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// The credentials directory systemd hands the unit; its absence names the variable.
|
||||
#[allow(clippy::disallowed_methods, reason = "the binary reads its own environment, and hands it to the library")]
|
||||
fn credentials_directory() -> Result<PathBuf> {
|
||||
std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from).ok_or_else(|| anyhow!("CREDENTIALS_DIRECTORY is not set"))
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
setting(name).ok_or_else(|| anyhow!("{name} is not set"))
|
||||
}
|
||||
|
|
@ -63,10 +75,12 @@ fn run() -> Result<()> {
|
|||
.ok_or_else(|| anyhow!("HUGINN_ENDPOINT is not rudp://<ip>:<port>"))?;
|
||||
let projector_model = required("MIND_STREAM_PROJECTOR_MODEL")?;
|
||||
let base_model = required("MIND_STREAM_BASE_MODEL")?;
|
||||
let model = OllamaStream::open(&required("MIND_STREAM_ENDPOINT")?, &projector_model, &base_model)?;
|
||||
let connector: SocketAddr = required("MIND_STREAM_CONNECTOR_ENDPOINT")?.parse().map_err(|_| anyhow!("MIND_STREAM_CONNECTOR_ENDPOINT is not <ip>:<port>"))?;
|
||||
let projector = KitProjector::open(connector, connector_key(&credentials_directory()?)?, &instance.0, &projector_model, PROJECTOR_TIMEOUT)?;
|
||||
let continuer = OllamaContinuer::open(&required("MIND_STREAM_ENDPOINT")?, &base_model)?;
|
||||
let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
|
||||
let persona = HuginnPersonaSource::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
|
||||
let organs = Organs { mind: &mind, persona: &persona, model: &model, projector_model: &projector_model, base_model: &base_model };
|
||||
let organs = Organs { mind: &mind, persona: &persona, projector: &projector, continuer: &continuer, base_model: &base_model };
|
||||
let mode = if args.dry_run { Mode::DryRun } else { Mode::Live };
|
||||
let once = args.once || args.dry_run;
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
//! The mouth is Bifrost's chat ear and mouth (cut `persona-mouth`). Five CultNet document kinds cross
|
||||
//! it, each a positional MessagePack tuple (fields in declaration order, `rmp_serde::to_vec`; the mouth refuses maps): the pending request and its answer, the
|
||||
//! ack, and the send request with its receipt. Their field order is the mouth's, pinned
|
||||
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the pending, ack and refusal kinds, `persona-chat-v2/` for the send
|
||||
//! and its receipt) in both directions. `chat_rudp::RudpChat` is
|
||||
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the ack and refusal kinds, `persona-chat-v2/` for the send
|
||||
//! and its receipt, `persona-chat-v5/` for the pending request and its answer) in both directions. `chat_rudp::RudpChat` is
|
||||
//! the RUDP transport that carries them; `Chat` is its seam, and `encode` and
|
||||
//! `decode` are its codec. Room aliases are the mouth's
|
||||
//! full aliases (`#operator:gamecult.org`), which the caller's `--rooms` carries.
|
||||
|
|
@ -17,15 +17,24 @@ use anyhow::{Context, Result};
|
|||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
|
||||
/// Document kinds, as the mouth names them.
|
||||
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v1";
|
||||
pub const PENDING: &str = "gamecult.persona.chat_pending.v2";
|
||||
/// The pending answer of a mouth that has no image field yet: its deliveries decode with `image: None`.
|
||||
/// The pending request as a mouth without the addressing facts reads it: `[persona, after_seq]`, answered with `chat_pending.v2`.
|
||||
pub const PENDING_REQUEST_V1: &str = "gamecult.persona.chat_pending_request.v1";
|
||||
/// The pending request, the same tuple. Asking by this kind is asking for the answer with the addressing facts.
|
||||
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v2";
|
||||
/// The answer to it: a delivery with its `basis` and `form` at tuple indices 12 and 13.
|
||||
pub const PENDING: &str = "gamecult.persona.chat_pending.v3";
|
||||
/// The answer a mouth gives a v1 request: no addressing facts, decoded as `basis: None, form: Message`.
|
||||
pub const PENDING_V2: &str = "gamecult.persona.chat_pending.v2";
|
||||
/// The pending answer of a mouth that has no image field either: its deliveries decode with `image: None`.
|
||||
pub const PENDING_V1: &str = "gamecult.persona.chat_pending.v1";
|
||||
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
|
||||
/// Her read receipt: the newest event of a room that went into her model's input.
|
||||
pub const READ: &str = "gamecult.persona.chat_read.v1";
|
||||
pub const SEND_REQUEST: &str = "gamecult.persona.chat_send_request.v2";
|
||||
pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v2";
|
||||
/// Her request that the mouth open a DM with a person, and its answer.
|
||||
pub const OPEN_DIRECT_REQUEST: &str = "gamecult.persona.chat_open_direct_request.v1";
|
||||
pub const OPEN_DIRECT_RECEIPT: &str = "gamecult.persona.chat_open_direct_receipt.v1";
|
||||
pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1";
|
||||
|
||||
/// One earlier line of a room, delivered with a message for its context.
|
||||
|
|
@ -55,6 +64,87 @@ pub struct ChatDelivery {
|
|||
/// The attachment the mouth stored for an image event. Tuple index 11; a v1 record ends at 10 and reads as none.
|
||||
#[serde(default)]
|
||||
pub image: Option<ChatImage>,
|
||||
/// Why Matrix says the message is for this Persona: tuple index 12. None is unproven, never "not for her"; an answer of an
|
||||
/// older mouth ends before it and reads as none.
|
||||
#[serde(default)]
|
||||
pub basis: Option<Basis>,
|
||||
/// What kind of event it is: tuple index 13. An answer of an older mouth ends before it and reads as a message.
|
||||
#[serde(default)]
|
||||
pub form: Form,
|
||||
}
|
||||
|
||||
/// Why the mouth found a delivery to be for the Persona, by what Matrix says (Bifrost `docs/bridge.md` "Addressing facts"). `Tag` and
|
||||
/// `Room` are the sender's claim in `m.mentions`; `Reply` is checked against the event it targets. Evidence, not authority: the
|
||||
/// runtime's own rule (`address::addresses`) weighs it. `Room` is reported and wakes no one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Basis {
|
||||
Direct,
|
||||
Tag,
|
||||
Reply,
|
||||
Room,
|
||||
}
|
||||
|
||||
impl Basis {
|
||||
const ALL: [Self; 4] = [Self::Direct, Self::Tag, Self::Reply, Self::Room];
|
||||
|
||||
/// The mouth's spelling: a MessagePack string.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Direct => "direct",
|
||||
Self::Tag => "tag",
|
||||
Self::Reply => "reply",
|
||||
Self::Room => "room",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Basis {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Basis {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Self::ALL.into_iter().find(|basis| basis.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a basis this side does not know"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of event a delivery is. Only a `Message` can address anyone: a `Notice` is an automated line and an `Edit` revises an
|
||||
/// earlier message.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Form {
|
||||
#[default]
|
||||
Message,
|
||||
Notice,
|
||||
Edit,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
const ALL: [Self; 3] = [Self::Message, Self::Notice, Self::Edit];
|
||||
|
||||
/// The mouth's spelling: a MessagePack string.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Message => "message",
|
||||
Self::Notice => "notice",
|
||||
Self::Edit => "edit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Form {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Form {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Self::ALL.into_iter().find(|form| form.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a form this side does not know"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An image the mouth fetched and stored, by reference: never its bytes. The path is relative to the Persona's image
|
||||
|
|
@ -78,6 +168,28 @@ pub struct ChatPendingRequest {
|
|||
pub struct ChatPending {
|
||||
pub persona: String,
|
||||
pub deliveries: Vec<ChatDelivery>,
|
||||
/// The mouth's room tail, tuple index 2 of `chat_pending.v3`: recent lines of the rooms she may speak in, oldest first, whether
|
||||
/// or not delivered or acked. An answer of a mouth that has none (v1, v2, or a v3 before the tail) ends before it and reads as
|
||||
/// none; a mouth with nothing to show sends an empty list, and that is a different fact.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tail: Option<Vec<TailLine>>,
|
||||
/// The rooms whose lines are not all in `tail`, tuple index 3: a room the mouth could not read in time, or whose lines it trimmed. A
|
||||
/// reader renders such a room as unknown, never quiet. An answer that ends before the field, or an empty list, names none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tail_gaps: Vec<String>,
|
||||
}
|
||||
|
||||
/// One line of the room tail: `[room, event_id, at_unix_ms, sender, form, basis, body]`. `room` is the configured alias (the room id
|
||||
/// for a DM); `basis` is what Matrix says on the event itself, none being unproven; `body` is untrusted text clipped by the mouth.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TailLine {
|
||||
pub room: String,
|
||||
pub event_id: String,
|
||||
pub at_unix_ms: u64,
|
||||
pub sender: String,
|
||||
pub form: Form,
|
||||
pub basis: Option<Basis>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -112,6 +224,8 @@ pub enum Refusal {
|
|||
UnknownPersona,
|
||||
/// A send with a basis found the room had moved past it: the receipt names what moved it.
|
||||
RoomMoved,
|
||||
/// The mouth does not serve this document kind. Answers an open-direct request only: a send receipt never carries it.
|
||||
UnknownKind,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
|
|
@ -125,6 +239,7 @@ impl Refusal {
|
|||
Self::SendFailed => "SendFailed",
|
||||
Self::UnknownPersona => "UnknownPersona",
|
||||
Self::RoomMoved => "RoomMoved",
|
||||
Self::UnknownKind => "UnknownKind",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,13 +253,26 @@ impl Serialize for Refusal {
|
|||
impl<'de> Deserialize<'de> for Refusal {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona, Self::RoomMoved]
|
||||
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona, Self::RoomMoved, Self::UnknownKind]
|
||||
.into_iter()
|
||||
.find(|refusal| refusal.name() == name)
|
||||
.ok_or_else(|| serde::de::Error::custom("the mouth named a refusal this side does not know"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouth refused a request outright with a typed refusal frame, and nothing was done. An error of `open_direct` when the
|
||||
/// mouth does not serve the kind: told as not sent, unlike a silent mouth, whose open may have gone out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MouthRefused(pub Refusal);
|
||||
|
||||
impl std::fmt::Display for MouthRefused {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "the mouth refused the request: {}", self.0.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MouthRefused {}
|
||||
|
||||
/// The mouth's typed answer to a request that names a Persona it does not
|
||||
/// serve: the pending request, the ack and the send alike.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -183,6 +311,117 @@ pub struct ChatSendReceipt {
|
|||
pub newer: Vec<String>,
|
||||
}
|
||||
|
||||
/// A request that the mouth open a DM with `user_id`, `text` being the invite: the ask, and the first line of the room.
|
||||
/// `request_id` is the mouth's transaction id, so a repeat of the same id is the same open. The mouth bounds `text` to
|
||||
/// 1..=4000 bytes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatOpenDirectRequest {
|
||||
pub request_id: String,
|
||||
pub persona: String,
|
||||
pub user_id: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// How the mouth answered an open, as its typed names. It reads consent from Matrix room state; none is kept here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DirectOutcome {
|
||||
Opened,
|
||||
Existing,
|
||||
Brake,
|
||||
PersonaMismatch,
|
||||
UnknownPersona,
|
||||
EmptyText,
|
||||
ForeignUser,
|
||||
NotAPerson,
|
||||
NoSuchUser,
|
||||
InvitePending,
|
||||
Declined,
|
||||
/// One creation per person per UTC day.
|
||||
DailyLimit,
|
||||
SendFailed,
|
||||
}
|
||||
|
||||
impl DirectOutcome {
|
||||
const ALL: [Self; 13] = [
|
||||
Self::Opened,
|
||||
Self::Existing,
|
||||
Self::Brake,
|
||||
Self::PersonaMismatch,
|
||||
Self::UnknownPersona,
|
||||
Self::EmptyText,
|
||||
Self::ForeignUser,
|
||||
Self::NotAPerson,
|
||||
Self::NoSuchUser,
|
||||
Self::InvitePending,
|
||||
Self::Declined,
|
||||
Self::DailyLimit,
|
||||
Self::SendFailed,
|
||||
];
|
||||
|
||||
/// The mouth's spelling: a MessagePack string.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Opened => "Opened",
|
||||
Self::Existing => "Existing",
|
||||
Self::Brake => "Brake",
|
||||
Self::PersonaMismatch => "PersonaMismatch",
|
||||
Self::UnknownPersona => "UnknownPersona",
|
||||
Self::EmptyText => "EmptyText",
|
||||
Self::ForeignUser => "ForeignUser",
|
||||
Self::NotAPerson => "NotAPerson",
|
||||
Self::NoSuchUser => "NoSuchUser",
|
||||
Self::InvitePending => "InvitePending",
|
||||
Self::Declined => "Declined",
|
||||
Self::DailyLimit => "DailyLimit",
|
||||
Self::SendFailed => "SendFailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DirectOutcome {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DirectOutcome {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Self::ALL.into_iter().find(|outcome| outcome.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named an outcome this side does not know"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouth's answer to an open. `room_id` and `event_id` are present exactly for `Opened` and `Existing`; a receipt
|
||||
/// that says otherwise does not decode.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "RawOpenReceipt")]
|
||||
pub struct ChatOpenDirectReceipt {
|
||||
pub request_id: String,
|
||||
pub outcome: DirectOutcome,
|
||||
pub room_id: Option<String>,
|
||||
pub event_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawOpenReceipt {
|
||||
request_id: String,
|
||||
outcome: DirectOutcome,
|
||||
room_id: Option<String>,
|
||||
event_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<RawOpenReceipt> for ChatOpenDirectReceipt {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(raw: RawOpenReceipt) -> Result<Self, Self::Error> {
|
||||
let landed = matches!(raw.outcome, DirectOutcome::Opened | DirectOutcome::Existing);
|
||||
if landed != (raw.room_id.is_some() && raw.event_id.is_some()) || (!landed && (raw.room_id.is_some() || raw.event_id.is_some())) {
|
||||
return Err("the open receipt's ids do not match its outcome");
|
||||
}
|
||||
Ok(Self { request_id: raw.request_id, outcome: raw.outcome, room_id: raw.room_id, event_id: raw.event_id })
|
||||
}
|
||||
}
|
||||
|
||||
/// A document as the mouth reads it: a positional tuple.
|
||||
pub fn encode<T: Serialize>(document: &T) -> Result<Vec<u8>> {
|
||||
rmp_serde::to_vec(document).context("the chat document does not encode")
|
||||
|
|
@ -195,10 +434,12 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
|
|||
|
||||
/// The Persona organ's chat: what is waiting, what to say, what is done.
|
||||
pub trait Chat {
|
||||
/// Deliveries after `after_seq`, oldest first. A `chat_refusal` answer is an
|
||||
/// Deliveries after `after_seq`, oldest first, with the mouth's room tail. A `chat_refusal` answer is an
|
||||
/// error that names the refusal.
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>>;
|
||||
fn pending(&self, after_seq: u64) -> Result<ChatPending>;
|
||||
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt>;
|
||||
/// Ask the mouth to open a DM. A refusal is a receipt; only a transport fault or a mouth that does not serve the kind is an error.
|
||||
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt>;
|
||||
/// Every delivery up to and including `through_seq` is answered; the mouth
|
||||
/// advances its sync position only now, so a start that dies first has its
|
||||
/// deliveries again.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,27 @@ const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/ga
|
|||
const PENDING_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
|
||||
const PENDING_NO_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.no-image.msgpack");
|
||||
const PENDING_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
|
||||
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
|
||||
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack");
|
||||
const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
|
||||
const PENDING_TAG_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack");
|
||||
const PENDING_UNPROVEN_NOTICE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack");
|
||||
// The three-field `chat_pending.v3` of a mouth with the room tail: Bifrost e8722d0's own golden bytes (`tests/fixtures/persona-chat-v5/`
|
||||
// there), written by its encoder `pendingV3Tuple`, copied here unchanged. The v5 files above are the two-field answer of a mouth before it.
|
||||
const PENDING_TAG_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack");
|
||||
const PENDING_UNPROVEN_NOTICE_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.unproven-notice.msgpack");
|
||||
const PENDING_TAIL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack");
|
||||
// The four-field `chat_pending.v3` with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged.
|
||||
const PENDING_GAPS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack");
|
||||
const SENT_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
|
||||
const ROOM_MOVED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.room-moved.msgpack");
|
||||
const NIL_BASIS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
|
||||
const SEEN_AND_SINCE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.seen-and-since.msgpack");
|
||||
const SINCE_ONLY_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.since-only.msgpack");
|
||||
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
|
||||
const OPENED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack");
|
||||
const EXISTING_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.existing.msgpack");
|
||||
const DECLINED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.declined.msgpack");
|
||||
const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack");
|
||||
|
||||
fn golden<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(bytes: &[u8], expected: &T) {
|
||||
assert_eq!(&decode::<T>(bytes).unwrap(), expected);
|
||||
|
|
@ -26,6 +41,8 @@ fn golden<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(bytes:
|
|||
#[test]
|
||||
fn the_pending_request_and_ack_are_the_mouths_bytes() {
|
||||
golden(PENDING_REQUEST_BYTES, &ChatPendingRequest { persona: "eureka".into(), after_seq: 3 });
|
||||
// The v1 kind carries the same tuple: asking by the v2 kind is the only difference.
|
||||
assert_eq!(PENDING_REQUEST_V1_BYTES, PENDING_REQUEST_BYTES);
|
||||
golden(ACK_BYTES, &ChatAck { persona: "eureka".into(), through_seq: 7 });
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +60,8 @@ fn delivery(image: Option<ChatImage>) -> ChatDelivery {
|
|||
mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()],
|
||||
is_direct: true,
|
||||
image,
|
||||
basis: None,
|
||||
form: Form::Message,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,18 +73,70 @@ fn image() -> ChatImage {
|
|||
#[test]
|
||||
fn a_v1_pending_answer_still_decodes_with_no_image() {
|
||||
let pending: ChatPending = decode(PENDING_V1_BYTES).unwrap();
|
||||
assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] });
|
||||
assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)], tail: None, tail_gaps: vec![] });
|
||||
assert_eq!(PENDING_V1_BYTES[0] & 0xf0, 0x90, "a positional tuple");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v2_pending_answer_is_the_golden_bytes_with_the_image_at_tuple_index_11() {
|
||||
golden(PENDING_NO_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] });
|
||||
golden(PENDING_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] });
|
||||
fn a_v2_pending_answer_decodes_with_the_image_at_tuple_index_11_and_no_addressing_facts() {
|
||||
let expected = |image| ChatPending { persona: "eureka".into(), deliveries: vec![delivery(image)], tail: None, tail_gaps: vec![] };
|
||||
assert_eq!(decode::<ChatPending>(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None));
|
||||
assert_eq!(decode::<ChatPending>(PENDING_IMAGE_BYTES).unwrap(), expected(Some(image())));
|
||||
// Index 11 of the delivery tuple, by position: 12 elements, the last an array of [path, mime, size, sha256].
|
||||
let value = shape::delivery_tuple(PENDING_IMAGE_BYTES);
|
||||
assert_eq!(value, shape::Probe { delivery_len: 12, image_len: Some(4) });
|
||||
assert_eq!(shape::delivery_tuple(PENDING_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: Some(4) });
|
||||
assert_eq!(shape::delivery_tuple(PENDING_NO_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: None });
|
||||
// A v1 request is answered with that 12-field tuple: no basis is unproven, and the event is a message.
|
||||
let old = decode::<ChatPending>(PENDING_IMAGE_BYTES).unwrap().deliveries.remove(0);
|
||||
assert_eq!((old.basis, old.form), (None, Form::Message));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v3_pending_answer_is_the_golden_bytes_with_basis_and_form_at_tuple_indices_12_and_13() {
|
||||
let one = |is_direct, mentions: &[&str], basis, form| {
|
||||
let mut delivery = delivery(None);
|
||||
(delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form);
|
||||
ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail: None, tail_gaps: vec![] }
|
||||
};
|
||||
golden(PENDING_TAG_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message));
|
||||
golden(PENDING_UNPROVEN_NOTICE_BYTES, &one(false, &[], None, Form::Notice));
|
||||
let fields = |bytes: &[u8]| {
|
||||
let value: serde_json::Value = rmp_serde::from_slice(bytes).unwrap();
|
||||
let tuple = value[1][0].as_array().unwrap().clone();
|
||||
(tuple.len(), tuple[12].clone(), tuple[13].clone())
|
||||
};
|
||||
assert_eq!(fields(PENDING_TAG_BYTES), (14, "tag".into(), "message".into()));
|
||||
assert_eq!(fields(PENDING_UNPROVEN_NOTICE_BYTES), (14, serde_json::Value::Null, "notice".into()));
|
||||
}
|
||||
|
||||
/// Deploy safety: an old mouth's two-field v3 and a new mouth's three-field v3 both decode; only the new one has a tail.
|
||||
#[test]
|
||||
fn the_pending_answer_decodes_with_the_tail_field_and_without_it() {
|
||||
let one = |is_direct, mentions: &[&str], basis, form, tail| {
|
||||
let mut delivery = delivery(None);
|
||||
(delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form);
|
||||
ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail, tail_gaps: vec![] }
|
||||
};
|
||||
for old in [PENDING_TAG_BYTES, PENDING_UNPROVEN_NOTICE_BYTES, PENDING_V1_BYTES, PENDING_NO_IMAGE_BYTES] {
|
||||
assert_eq!(decode::<ChatPending>(old).unwrap().tail, None, "a mouth with no tail sends none");
|
||||
}
|
||||
golden(PENDING_TAG_TAILED_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message, Some(vec![])));
|
||||
golden(PENDING_UNPROVEN_NOTICE_TAILED_BYTES, &one(false, &[], None, Form::Notice, Some(vec![])));
|
||||
let tailed: ChatPending = decode(PENDING_TAIL_BYTES).unwrap();
|
||||
assert!(tailed.deliveries.is_empty());
|
||||
let line = |event: &str, at, form, basis, body: &str| TailLine { room: "#operator:gamecult.org".into(), event_id: event.into(), at_unix_ms: at, sender: "@meta:gamecult.org".into(), form, basis, body: body.into() };
|
||||
assert_eq!(tailed.tail, Some(vec![line("$e0", 1_700_000_000_000, Form::Message, None, "earlier"), line("$e1", 1_700_000_001_000, Form::Notice, Some(Basis::Tag), "hello eureka")]));
|
||||
golden(PENDING_TAIL_BYTES, &tailed);
|
||||
assert_eq!(rmp_serde::from_slice::<serde_json::Value>(PENDING_TAIL_BYTES).unwrap()[2][0].as_array().unwrap().len(), 7, "a tail line is seven fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_basis_or_form_this_side_does_not_know_does_not_decode() {
|
||||
let mut value: serde_json::Value = rmp_serde::from_slice(PENDING_TAG_BYTES).unwrap();
|
||||
value[1][0][12] = "everyone".into();
|
||||
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
|
||||
value[1][0][12] = "tag".into();
|
||||
value[1][0][13] = "reaction".into();
|
||||
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// Reads the tuple's shape from the raw bytes, independent of the serde types under test.
|
||||
|
|
@ -99,11 +170,74 @@ fn a_send_and_its_receipt_are_the_mouths_bytes() {
|
|||
golden(ROOM_MOVED_BYTES, &ChatSendReceipt { request_id: "req-1".into(), event_id: None, refusal: Some(Refusal::RoomMoved), newer: vec!["$a".into(), "$b".into()] });
|
||||
}
|
||||
|
||||
/// Writes the v2 fixtures; run once with `WRITE_CHAT_FIXTURES=1` when the wire changes.
|
||||
#[test]
|
||||
#[ignore = "regenerates the golden fixtures"]
|
||||
fn write_the_v2_pending_fixtures() {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/persona-chat-v3/");
|
||||
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.no-image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] }).unwrap()).unwrap();
|
||||
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] }).unwrap()).unwrap();
|
||||
fn open_direct_fixtures_round_trip() {
|
||||
golden(OPEN_REQUEST_BYTES, &ChatOpenDirectRequest { request_id: "req-1".into(), persona: "eureka".into(), user_id: "@meta:gamecult.org".into(), text: "hello Meta".into() });
|
||||
golden(OPENED_BYTES, &ChatOpenDirectReceipt { request_id: "req-1".into(), outcome: DirectOutcome::Opened, room_id: Some("!dm1:gamecult.org".into()), event_id: Some("$line1".into()) });
|
||||
golden(EXISTING_BYTES, &ChatOpenDirectReceipt { request_id: "req-2".into(), outcome: DirectOutcome::Existing, room_id: Some("!dm1:gamecult.org".into()), event_id: Some("$line2".into()) });
|
||||
golden(DECLINED_BYTES, &ChatOpenDirectReceipt { request_id: "req-3".into(), outcome: DirectOutcome::Declined, room_id: None, event_id: None });
|
||||
golden(UNKNOWN_KIND_BYTES, &ChatRefusal { persona: "eureka".into(), refusal: Refusal::UnknownKind });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_outcome_is_spelled_as_the_mouth_spells_it() {
|
||||
let all = [
|
||||
(DirectOutcome::Opened, "Opened"),
|
||||
(DirectOutcome::Existing, "Existing"),
|
||||
(DirectOutcome::Brake, "Brake"),
|
||||
(DirectOutcome::PersonaMismatch, "PersonaMismatch"),
|
||||
(DirectOutcome::UnknownPersona, "UnknownPersona"),
|
||||
(DirectOutcome::EmptyText, "EmptyText"),
|
||||
(DirectOutcome::ForeignUser, "ForeignUser"),
|
||||
(DirectOutcome::NotAPerson, "NotAPerson"),
|
||||
(DirectOutcome::NoSuchUser, "NoSuchUser"),
|
||||
(DirectOutcome::InvitePending, "InvitePending"),
|
||||
(DirectOutcome::Declined, "Declined"),
|
||||
(DirectOutcome::DailyLimit, "DailyLimit"),
|
||||
(DirectOutcome::SendFailed, "SendFailed"),
|
||||
];
|
||||
for (outcome, name) in all {
|
||||
assert_eq!(outcome.name(), name);
|
||||
let refused = outcome != DirectOutcome::Opened && outcome != DirectOutcome::Existing;
|
||||
let ids = if refused { (None, None) } else { (Some("!r:x".to_string()), Some("$e".to_string())) };
|
||||
let receipt = ChatOpenDirectReceipt { request_id: "q".into(), outcome, room_id: ids.0, event_id: ids.1 };
|
||||
assert_eq!(decode::<ChatOpenDirectReceipt>(&encode(&receipt).unwrap()).unwrap(), receipt, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_with_ids_on_a_refusal_does_not_decode() {
|
||||
let wire = |outcome: &str, room: Option<&str>, event: Option<&str>| rmp_serde::to_vec(&("q", outcome, room, event)).unwrap();
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", Some("!r:x"), Some("$e"))).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", Some("!r:x"), None)).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", None, Some("$e"))).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Opened", None, None)).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Opened", Some("!r:x"), None)).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Existing", None, Some("$e"))).is_err());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Existing", Some("!r:x"), Some("$e"))).is_ok());
|
||||
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", None, None)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_error_never_echoes_what_it_read() {
|
||||
let canary = "CANARY-9f3a";
|
||||
let bad_outcome = rmp_serde::to_vec(&("q", canary, None::<&str>, None::<&str>)).unwrap();
|
||||
let bad_ids = rmp_serde::to_vec(&(canary, "Declined", Some(canary), Some(canary))).unwrap();
|
||||
for bytes in [bad_outcome, bad_ids] {
|
||||
let error = format!("{:#}", decode::<ChatOpenDirectReceipt>(&bytes).unwrap_err());
|
||||
assert!(!error.contains(canary), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `tail_gaps` is a defaulted fourth element: named rooms round-trip as the mouth's bytes, and an empty list encodes as the three-field answer.
|
||||
#[test]
|
||||
fn the_pending_answer_decodes_tail_gaps_and_defaults_them_to_none() {
|
||||
let gapped: ChatPending = decode(PENDING_GAPS_BYTES).unwrap();
|
||||
assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()]);
|
||||
golden(PENDING_GAPS_BYTES, &gapped);
|
||||
let tailed: ChatPending = decode(PENDING_TAIL_BYTES).unwrap();
|
||||
assert!(tailed.tail_gaps.is_empty());
|
||||
let mut empty_fourth: serde_json::Value = rmp_serde::from_slice(PENDING_TAIL_BYTES).unwrap();
|
||||
empty_fourth.as_array_mut().unwrap().push(serde_json::json!([]));
|
||||
assert_eq!(decode::<ChatPending>(&rmp_serde::to_vec(&empty_fourth).unwrap()).unwrap(), tailed, "an empty fourth element is the same answer");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use serde::de::DeserializeOwned;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::chat_client::{
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V2, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
|
||||
SEND_REQUEST, decode, encode,
|
||||
};
|
||||
|
||||
|
|
@ -92,14 +92,14 @@ impl RudpChat {
|
|||
Ok(Link { transport, runtime_id })
|
||||
}
|
||||
|
||||
/// Puts one document and, when `answer` names a kind and record key, waits for
|
||||
/// Puts one document and, when `answer` names (kind, record key) pairs, waits for
|
||||
/// the document the mouth answers it with. Documents of other kinds or keys
|
||||
/// (a late receipt of an earlier send) are skipped.
|
||||
///
|
||||
/// A failed exchange drops the link: a restarted mouth silently ignores packets from a sender it has not connected, so a held link
|
||||
/// would time out on every call after it. The next call opens a fresh one. Nothing is repeated on it: the caller's request id is
|
||||
/// what makes a repeat of a send the same send.
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let result = self.exchange_on_link(kind, record_key, document, answer);
|
||||
if result.is_err()
|
||||
&& let Some(mut link) = self.link.borrow_mut().take()
|
||||
|
|
@ -109,7 +109,7 @@ impl RudpChat {
|
|||
result
|
||||
}
|
||||
|
||||
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let mut slot = self.link.borrow_mut();
|
||||
if slot.is_none() {
|
||||
*slot = Some(self.open()?);
|
||||
|
|
@ -131,7 +131,7 @@ impl RudpChat {
|
|||
};
|
||||
link.transport.send_schema_message(&message)?;
|
||||
let deadline = Instant::now() + self.reply_timeout;
|
||||
let Some((answer_kinds, answer_key)) = answer else {
|
||||
let Some(answers) = answer else {
|
||||
// Nothing is answered; the ack is done when the mouth has the packet.
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
link.transport.flush_reliable(left)?;
|
||||
|
|
@ -144,7 +144,7 @@ impl RudpChat {
|
|||
link.transport.poll_resends()?;
|
||||
if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? {
|
||||
let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]);
|
||||
if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key {
|
||||
if tagged && answers.contains(&(document.schema_id.as_str(), document.record_key.as_str())) {
|
||||
return Ok(Some((document.schema_id, document.payload)));
|
||||
}
|
||||
}
|
||||
|
|
@ -152,16 +152,21 @@ impl RudpChat {
|
|||
}
|
||||
|
||||
fn call<Q: Serialize, A: DeserializeOwned>(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result<A> {
|
||||
let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
let (_, payload) = self.exchange(kind, key, request, Some(&[answer]))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
decode(&payload)
|
||||
}
|
||||
}
|
||||
|
||||
impl Chat for RudpChat {
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
|
||||
fn pending(&self, after_seq: u64) -> Result<ChatPending> {
|
||||
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
|
||||
let kinds: &[&str] = &[PENDING, PENDING_V1, REFUSAL];
|
||||
let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
let p = self.persona.as_str();
|
||||
let answers = [(PENDING, p), (PENDING_V2, p), (PENDING_V1, p), (REFUSAL, p)];
|
||||
let (mut kind, mut payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
// A mouth that does not serve the v2 request answers `UnknownKind`: ask it the v1 way, and its deliveries carry no addressing facts.
|
||||
if kind == REFUSAL && decode::<ChatRefusal>(&payload)?.refusal == Refusal::UnknownKind {
|
||||
(kind, payload) = self.exchange(PENDING_REQUEST_V1, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
}
|
||||
if kind == REFUSAL {
|
||||
let refusal: ChatRefusal = decode(&payload)?;
|
||||
bail!("the mouth refused the pending request: {}", refusal.refusal.name());
|
||||
|
|
@ -170,7 +175,7 @@ impl Chat for RudpChat {
|
|||
if pending.persona != self.persona {
|
||||
bail!("the mouth answered for another Persona");
|
||||
}
|
||||
Ok(pending.deliveries)
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt> {
|
||||
|
|
@ -181,6 +186,26 @@ impl Chat for RudpChat {
|
|||
Ok(receipt)
|
||||
}
|
||||
|
||||
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt> {
|
||||
// A mouth that does not serve the kind answers with a refusal keyed by the persona, not by the request: heard here,
|
||||
// it ends the wait, and the open is known not to have been sent.
|
||||
let p = self.persona.as_str();
|
||||
let answers = [(OPEN_DIRECT_RECEIPT, request.request_id.as_str()), (REFUSAL, p)];
|
||||
let (kind, payload) = self.exchange(OPEN_DIRECT_REQUEST, &request.request_id, request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
if kind == REFUSAL {
|
||||
let refusal: ChatRefusal = decode(&payload)?;
|
||||
if refusal.persona != self.persona || refusal.refusal != Refusal::UnknownKind {
|
||||
bail!("the mouth refused the open: {}", refusal.refusal.name());
|
||||
}
|
||||
return Err(MouthRefused(refusal.refusal).into());
|
||||
}
|
||||
let receipt: ChatOpenDirectReceipt = decode(&payload)?;
|
||||
if receipt.request_id != request.request_id {
|
||||
bail!("the mouth answered another open");
|
||||
}
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
fn ack(&self, through_seq: u64) -> Result<()> {
|
||||
let ack = ChatAck { persona: self.persona.clone(), through_seq };
|
||||
self.exchange(ACK, &format!("{}:{through_seq}", self.persona), &ack, None).map(|_| ())
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eureka_pipeline::{RunTurn, Slug};
|
||||
use eureka_pipeline::Slug;
|
||||
|
||||
use crate::control::{
|
||||
ControlWriter, Effective, FileSource, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
|
||||
};
|
||||
use crate::launch::{StopScope, unit};
|
||||
use crate::launch::{StopScope, stop_args};
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | mind-control [--root <dir>] swarm show | brake hold | brake release";
|
||||
|
|
@ -110,7 +110,7 @@ pub fn run(
|
|||
let stopped = stop_units(StopScope::Mind(&instance));
|
||||
match (written, stopped) {
|
||||
(Ok(()), Ok(())) => {
|
||||
writeln!(out, "units: stopped {} {}", unit(&instance, RunTurn::SelfRun)?, unit(&instance, RunTurn::PersonaTurn)?)?;
|
||||
writeln!(out, "units: stopped {}", stop_args(StopScope::Mind(&instance))?[2..].join(" "))?;
|
||||
Ok(())
|
||||
}
|
||||
outcome => held(outcome),
|
||||
|
|
@ -151,7 +151,7 @@ fn swarm(
|
|||
let written = SwarmWriter::open(&path).and_then(|mut writer| writer.set_brake(false, now, set_by));
|
||||
let stopped = stop_units(StopScope::Swarm);
|
||||
if written.is_ok() && stopped.is_ok() {
|
||||
writeln!(out, "units: stopped every mind-self@ and mind-persona@ unit")?;
|
||||
writeln!(out, "units: stopped every {}", stop_args(StopScope::Swarm)?[2..].join(" "))?;
|
||||
}
|
||||
held((written, stopped))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ use anyhow::{Context, Result, anyhow, bail};
|
|||
use chrono::{DateTime, SecondsFormat, Timelike, Utc};
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
|
||||
use eureka_pipeline::{OrgRepo, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
|
||||
use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems};
|
||||
|
||||
use crate::address::{Addressee, Name, addresses, project_names};
|
||||
use crate::address::{Addressee, Name, is_for, project_names};
|
||||
use crate::chat_client::{Chat, ChatDelivery};
|
||||
use crate::control::{ControlSource, Effective, FileSource, HeldReason, SwarmEffective, read_effective, read_envelope, set_mode_world_readable};
|
||||
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch, slot};
|
||||
|
|
@ -502,15 +502,16 @@ fn newest(mind: &dyn MindPort, selection: Selection) -> Result<Option<PipelineDo
|
|||
Ok(found.into_iter().next())
|
||||
}
|
||||
|
||||
/// The chat names of every repo she stewards in force: one query on her mind.
|
||||
fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
|
||||
let mut names = Vec::new();
|
||||
/// Every repo she stewards in force and the chat names of those repos: one query on her mind.
|
||||
pub(crate) fn stewardships_of(mind: &dyn MindPort) -> Result<(Vec<Name>, Vec<OrgRepo>)> {
|
||||
let (mut names, mut repos) = (Vec::new(), Vec::new());
|
||||
for header in headers(mind, &in_force(of_kind(PipelineKind::Stewardship)))? {
|
||||
if let PipelineFacts::Stewardship { repo, names: listed, .. } = &header.facts {
|
||||
names.extend(project_names(repo, listed));
|
||||
repos.push(repo.clone());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
Ok((names, repos))
|
||||
}
|
||||
|
||||
/// Whether a delivery wakes `who` off the beat: the operator or another Mind of
|
||||
|
|
@ -518,7 +519,7 @@ fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
|
|||
fn wakes(config: &Config, who: &Addressee, delivery: &ChatDelivery) -> bool {
|
||||
let from_operator = config.operators.contains(&delivery.sender);
|
||||
let from_other_mind = delivery.sender != who.matrix_id() && config.personas.iter().any(|mind| mind.matrix_id == delivery.sender);
|
||||
(from_operator || from_other_mind) && addresses(who, delivery)
|
||||
(from_operator || from_other_mind) && is_for(who, delivery)
|
||||
}
|
||||
|
||||
/// The mouth pages a pending answer, so fewer deliveries may come than wait.
|
||||
|
|
@ -528,7 +529,7 @@ fn read_pending(chat: &dyn Chat) -> Result<Vec<ChatDelivery>> {
|
|||
let mut all: Vec<ChatDelivery> = Vec::new();
|
||||
let mut after = 0;
|
||||
for _ in 0..PENDING_PAGES {
|
||||
let page = chat.pending(after)?;
|
||||
let page = chat.pending(after)?.deliveries;
|
||||
let Some(top) = page.iter().map(|delivery| delivery.seq).max() else {
|
||||
break;
|
||||
};
|
||||
|
|
@ -633,7 +634,7 @@ impl Row<'_> {
|
|||
|
||||
/// A member (not the operator) has a pending message that names or tags her.
|
||||
fn addressed_by_member(&self, config: &Config) -> bool {
|
||||
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && addresses(&self.addressee, delivery))
|
||||
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && is_for(&self.addressee, delivery))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -771,8 +772,8 @@ pub fn poll(organs: &Organs, config: &Config) -> Result<Outcome> {
|
|||
}
|
||||
// Her projects' names address her too; read only when something is pending. A failed read keeps her last-read row.
|
||||
if !row.pending.is_empty() {
|
||||
match project_names_of(seat.mind) {
|
||||
Ok(names) => row.addressee = roster.with_projects(names),
|
||||
match stewardships_of(seat.mind) {
|
||||
Ok((names, repos)) => row.addressee = roster.with_stewardships(names, repos),
|
||||
Err(error) => row.note = format!("{} stewardship names unread: {error}", row.note).trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use chrono::{Duration, TimeZone};
|
|||
use eureka_pipeline::PipelineKind as K;
|
||||
use huginn_mind::PipelineSelectionPage;
|
||||
use tempfile::TempDir;
|
||||
use crate::address::addresses;
|
||||
|
||||
use super::*;
|
||||
use crate::control::{Brake, ControlState, SwarmState};
|
||||
|
|
@ -87,15 +88,19 @@ struct FakeChat {
|
|||
}
|
||||
|
||||
impl Chat for FakeChat {
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
|
||||
fn pending(&self, after_seq: u64) -> Result<crate::chat_client::ChatPending> {
|
||||
self.pending_calls.set(self.pending_calls.get() + 1);
|
||||
Ok(self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect())
|
||||
Ok(crate::chat_client::ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: None, tail_gaps: vec![] })
|
||||
}
|
||||
|
||||
fn send(&self, _: &crate::chat_client::ChatSendRequest) -> Result<crate::chat_client::ChatSendReceipt> {
|
||||
panic!("the heartbeat sends nothing")
|
||||
}
|
||||
|
||||
fn open_direct(&self, _: &crate::chat_client::ChatOpenDirectRequest) -> Result<crate::chat_client::ChatOpenDirectReceipt> {
|
||||
panic!("the heartbeat opens nothing")
|
||||
}
|
||||
|
||||
fn ack(&self, _: u64) -> Result<()> {
|
||||
panic!("the heartbeat never acks a delivery")
|
||||
}
|
||||
|
|
@ -157,6 +162,8 @@ fn delivery(seq: u64, sender: &str, text: &str, mentions: &[&str]) -> ChatDelive
|
|||
mentions: mentions.iter().map(|id| (*id).to_string()).collect(),
|
||||
is_direct: false,
|
||||
image: None,
|
||||
basis: None,
|
||||
form: crate::chat_client::Form::Message,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -634,6 +641,8 @@ fn the_heartbeat_writes_the_table_it_read_and_only_when_it_changed() {
|
|||
rig.poll();
|
||||
assert_eq!(rig.store.addressing_saves.get(), 2);
|
||||
assert!(addresses(&table_row(&rig, "@bob:chat"), &delivery(9, "@op:chat", "BDS is down", &[])));
|
||||
assert!(table_row(&rig, "@bob:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into())), "the row carries the repos her names came from");
|
||||
assert!(!table_row(&rig, "@ann:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into())));
|
||||
assert!(addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "Ann?", &[])) && !addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "BDS", &[])));
|
||||
rig.poll();
|
||||
assert_eq!(rig.store.addressing_saves.get(), 2, "an identical poll writes nothing");
|
||||
|
|
@ -1423,7 +1432,7 @@ fn the_file_store_reads_back_the_addressing_it_wrote_to_the_view() {
|
|||
let dir = TempDir::new().unwrap();
|
||||
let store = FileStore::new(dir.path().join("heartbeat.cc"));
|
||||
assert_eq!(store.addressing().unwrap(), None);
|
||||
let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_projects([Name { text: "BDS".into(), exact: true }])] };
|
||||
let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_stewardships([Name { text: "BDS".into(), exact: true }], [])] };
|
||||
store.save_addressing(&table).unwrap();
|
||||
assert_eq!(store.addressing().unwrap(), Some(table.clone()));
|
||||
assert_eq!(store.load_addressing().unwrap(), Some(table));
|
||||
|
|
@ -1552,3 +1561,34 @@ fn a_sleeping_mind_is_not_eligible_for_the_beat() {
|
|||
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![Slug("ann".into()), Slug("bob".into())] });
|
||||
assert_eq!(started_turns(&rig), vec![("ann".to_string(), RunTurn::Sleep), ("bob".to_string(), RunTurn::PersonaTurn)], "the beat went to the Mind who was awake, though ann's urgency was the higher");
|
||||
}
|
||||
|
||||
/// Matrix's word that a message is her DM, a tag or a reply wakes her off the beat from the operator; a notice or an edit never does,
|
||||
/// whatever its basis, and a stranger's direct message is delivered but wakes no one.
|
||||
#[test]
|
||||
fn matrixs_addressing_facts_wake_her_off_the_beat_only_on_a_message_from_the_roster() {
|
||||
use crate::chat_client::{Basis, Form};
|
||||
let with = |seq, sender: &str, basis, form| ChatDelivery { basis, form, ..delivery(seq, sender, "no name here", &[]) };
|
||||
for (basis, form, wakes) in [
|
||||
(Some(Basis::Direct), Form::Message, true),
|
||||
(Some(Basis::Tag), Form::Message, true),
|
||||
(Some(Basis::Reply), Form::Message, true),
|
||||
(Some(Basis::Room), Form::Message, false),
|
||||
(None, Form::Message, false),
|
||||
(Some(Basis::Direct), Form::Notice, false),
|
||||
(Some(Basis::Tag), Form::Edit, false),
|
||||
] {
|
||||
let rig = Rig::new();
|
||||
rig.turn(ANN, 10);
|
||||
rig.turn(BOB, 10);
|
||||
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
|
||||
rig.say(BOB, with(1, "@op:chat", basis, form));
|
||||
let launched = if wakes { vec![Slug("bob".into())] } else { vec![] };
|
||||
assert_eq!(rig.poll(), Outcome::Polled { launched }, "{basis:?} {form:?}");
|
||||
}
|
||||
let rig = Rig::new();
|
||||
rig.turn(ANN, 10);
|
||||
rig.turn(BOB, 10);
|
||||
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
|
||||
rig.say(BOB, with(1, "@stranger:chat", Some(Basis::Direct), Form::Message));
|
||||
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }, "the sender gate stays: a stranger's DM is delivered and wakes no one off the beat");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,15 +124,23 @@ fn template(turn: RunTurn) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The unit of one turn: the template by turn, then the instance, held to the
|
||||
/// leaf's grammar first so it cannot escape the name. The error names nothing
|
||||
/// of the value. Start and alive both name the unit here.
|
||||
pub(crate) fn unit(instance: &Slug, turn: RunTurn) -> Result<String> {
|
||||
/// The stream's unit template: not a run slot, so only a brake hold names it.
|
||||
const STREAM_TEMPLATE: &str = "mind-stream";
|
||||
|
||||
/// A template's unit for one instance, the instance held to the leaf's grammar
|
||||
/// first so it cannot escape the name. The error names nothing of the value.
|
||||
fn named(template: &str, instance: &Slug) -> Result<String> {
|
||||
instance.validate_slug().map_err(|_| anyhow!("the instance is not a unit name"))?;
|
||||
Ok(format!("{}@{}.service", template(turn), instance.0))
|
||||
Ok(format!("{template}@{}.service", instance.0))
|
||||
}
|
||||
|
||||
/// What one stop reaches: the two units of one Mind, or every Mind's.
|
||||
/// The unit of one turn: the template by turn, then the instance. Start and
|
||||
/// alive both name the unit here.
|
||||
pub(crate) fn unit(instance: &Slug, turn: RunTurn) -> Result<String> {
|
||||
named(template(turn), instance)
|
||||
}
|
||||
|
||||
/// What one stop reaches: the three units of one Mind, or every Mind's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StopScope<'a> {
|
||||
Mind(&'a Slug),
|
||||
|
|
@ -141,22 +149,25 @@ pub enum StopScope<'a> {
|
|||
|
||||
/// The arguments of the one synchronous stop. A Mind's: the exact unit `unit()`
|
||||
/// names for each turn of this instance, the same names start and alive use,
|
||||
/// with no glob (a prefix glob also matches another instance, `eureka*` matches
|
||||
/// and its stream's, with no glob (a prefix glob also matches another instance, `eureka*` matches
|
||||
/// `eureka-two`). The swarm's: each template's glob over the loaded units,
|
||||
/// which is exactly every running Mind and exits 0 when none is loaded. No
|
||||
/// `--no-block`: `systemctl stop` returns when the units are down. The
|
||||
/// instance is validated by `unit()`.
|
||||
/// instance is validated by `named()`.
|
||||
pub(crate) fn stop_args(scope: StopScope<'_>) -> Result<Vec<String>> {
|
||||
let mut args = vec!["stop".to_string(), "--".to_string()];
|
||||
for turn in [RunTurn::SelfRun, RunTurn::PersonaTurn] {
|
||||
args.push(match scope {
|
||||
StopScope::Mind(instance) => unit(instance, turn)?,
|
||||
StopScope::Swarm => format!("{}@*.service", template(turn)),
|
||||
});
|
||||
}
|
||||
args.extend(held_templates().into_iter().map(|template| match scope {
|
||||
StopScope::Mind(instance) => named(template, instance),
|
||||
StopScope::Swarm => Ok(format!("{template}@*.service")),
|
||||
}).collect::<Result<Vec<_>>>()?);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// The templates a hold stops: both run slots and the stream.
|
||||
fn held_templates() -> [&'static str; 3] {
|
||||
[template(RunTurn::SelfRun), template(RunTurn::PersonaTurn), STREAM_TEMPLATE]
|
||||
}
|
||||
|
||||
impl SystemdLauncher {
|
||||
/// Stops the units of one Mind, or of every Mind, and waits for them. An
|
||||
/// inherent method, not on `Launcher`: only a brake hold calls it, through
|
||||
|
|
@ -173,10 +184,10 @@ impl SystemdLauncher {
|
|||
}
|
||||
let states = match scope {
|
||||
StopScope::Mind(instance) => {
|
||||
let states = [RunTurn::SelfRun, RunTurn::PersonaTurn]
|
||||
let states = held_templates()
|
||||
.into_iter()
|
||||
.map(|turn| {
|
||||
let unit = unit(instance, turn)?;
|
||||
.map(|template| {
|
||||
let unit = named(template, instance)?;
|
||||
let state = self.unit_state(&unit);
|
||||
Ok(format!("{unit} is {state}"))
|
||||
})
|
||||
|
|
@ -1486,10 +1497,12 @@ mod tests {
|
|||
launcher.start(&instance, turn).unwrap();
|
||||
started.push(spy.calls.borrow().last().unwrap().last().unwrap().clone());
|
||||
}
|
||||
assert_eq!(args[2..], started[..], "stop names exactly the units start names, for both turns");
|
||||
assert_eq!(args[2..4], started[..], "stop names exactly the units start names, for both turns");
|
||||
assert_eq!(args[4], format!("mind-stream@{name}.service"), "and the stream of that instance");
|
||||
assert_eq!(args.len(), 5);
|
||||
assert!(args[2..].iter().all(|unit| !unit.contains(['*', '?', '['])), "a glob can match another instance or none: {args:?}");
|
||||
}
|
||||
assert_eq!(stop_args(StopScope::Mind(&eureka())).unwrap(), ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"]);
|
||||
assert_eq!(stop_args(StopScope::Mind(&eureka())).unwrap(), ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1503,7 +1516,7 @@ mod tests {
|
|||
fn the_swarm_stop_is_one_stop_of_both_templates_globs() {
|
||||
let (launcher, spy) = spied();
|
||||
launcher.stop(StopScope::Swarm).unwrap();
|
||||
assert_eq!(*spy.calls.borrow(), vec![vec!["stop", "--", "mind-self@*.service", "mind-persona@*.service"]]);
|
||||
assert_eq!(*spy.calls.borrow(), vec![vec!["stop", "--", "mind-self@*.service", "mind-persona@*.service", "mind-stream@*.service"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1535,12 +1548,12 @@ mod tests {
|
|||
let (launcher, spy) = spied();
|
||||
spy.success.set(false);
|
||||
spy.code.set(Some(5));
|
||||
spy.states.borrow_mut().extend([("mind-self@eureka.service", "inactive\n"), ("mind-persona@eureka.service", "active\n")]);
|
||||
spy.states.borrow_mut().extend([("mind-self@eureka.service", "inactive\n"), ("mind-persona@eureka.service", "active\n"), ("mind-stream@eureka.service", "failed\n")]);
|
||||
let shown = format!("{:#}", launcher.stop(StopScope::Mind(&eureka())).unwrap_err());
|
||||
assert!(shown.contains("exit status 5"), "{shown}");
|
||||
assert!(shown.contains("mind-self@eureka.service is inactive") && shown.contains("mind-persona@eureka.service is active"), "{shown}");
|
||||
assert!(shown.contains("mind-self@eureka.service is inactive") && shown.contains("mind-persona@eureka.service is active") && shown.contains("mind-stream@eureka.service is failed"), "{shown}");
|
||||
let asked: Vec<Vec<String>> = spy.calls.borrow().iter().skip(1).cloned().collect();
|
||||
assert_eq!(asked, [["is-active", "mind-self@eureka.service"], ["is-active", "mind-persona@eureka.service"]], "one query per unit, after the one stop");
|
||||
assert_eq!(asked, [["is-active", "mind-self@eureka.service"], ["is-active", "mind-persona@eureka.service"], ["is-active", "mind-stream@eureka.service"]], "one query per unit, after the one stop");
|
||||
// A state word outside the three, and anything systemctl prints with it, is not carried.
|
||||
spy.states.borrow_mut().clear();
|
||||
*spy.stdout.borrow_mut() = "canary-output-7f3a91\n".to_string();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ use std::collections::BTreeMap;
|
|||
use anyhow::{Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind};
|
||||
use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind, RulingAuthority};
|
||||
use huginn_mind::{PipelineDocumentView, PipelinePageItems};
|
||||
|
||||
use crate::persona::quoted;
|
||||
|
|
@ -248,7 +248,19 @@ fn line_of(view: &PipelineDocumentView, names: &BTreeMap<&str, &str>) -> Option<
|
|||
match &view.document {
|
||||
PipelineDocument::Memory(_) => memory_line(view, names, false),
|
||||
PipelineDocument::FollowUp(follow_up) => Some(format!("- You set yourself to: {}\n", quoted(follow_up.item.0.lines().next().unwrap_or("")))),
|
||||
PipelineDocument::Ruling(ruling) => Some(format!("- Ruled: {}\n", quoted(&ruling.title.0))),
|
||||
PipelineDocument::Ruling(ruling) => {
|
||||
let title = quoted(&ruling.title.0);
|
||||
Some(match &ruling.authority {
|
||||
RulingAuthority::Steward { steward, repo, .. } => {
|
||||
// A display name is a member's text: keep it to one line.
|
||||
let who: String = names.get(steward.0.as_str()).copied().unwrap_or(steward.0.as_str()).chars().filter(|c| !c.is_control()).collect();
|
||||
format!("- {who} ruled, as steward of {}: {title}\n", repo.0)
|
||||
}
|
||||
RulingAuthority::Mind => format!("- You ruled: {title}\n"),
|
||||
RulingAuthority::Operator => format!("- The operator ruled: {title}\n"),
|
||||
RulingAuthority::Standing | RulingAuthority::Defaulted => format!("- Ruled: {title}\n"),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,6 +402,28 @@ fn a_follow_up_and_a_ruling_read_as_what_she_set_herself_and_what_was_ruled() {
|
|||
assert!(lines.contains(&"- Ruled: \"A ruling\"\n"), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruling_names_who_decided_it() {
|
||||
let mind = TestMind::seeded();
|
||||
mind.committed(vec![spec("x")]);
|
||||
let by = |label: &str, authority: RulingAuthority| match ruling(label, None) {
|
||||
PipelineDocument::Ruling(mut ruling) => {
|
||||
ruling.authority = authority;
|
||||
PipelineDocument::Ruling(ruling)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let steward = RulingAuthority::Steward { steward: "@heimdall:chat".into(), repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()), words: "no zeros".into(), event: None };
|
||||
mind.committed(vec![follow_up("fork-it", &spec_ref("x")), by("R1", steward), by("R2", RulingAuthority::Mind), by("R3", RulingAuthority::Operator)]);
|
||||
let mut asking = cue(&[LIBBY], &["Later"], &[]);
|
||||
asking.names = BTreeMap::from([("@heimdall:chat", "Heimdall")]);
|
||||
let recalled = select(&Recording::over(&mind), &asking).unwrap();
|
||||
let lines: Vec<&str> = recalled.entries().map(|entry| entry.line.as_str()).collect();
|
||||
for expected in ["- Heimdall ruled, as steward of GameCult/Bifrost: \"A ruling\"\n", "- You ruled: \"A ruling\"\n", "- The operator ruled: \"A ruling\"\n"] {
|
||||
assert!(lines.contains(&expected), "{expected}: {lines:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_notice_appears_only_when_nothing_is_remembered_or_left_out() {
|
||||
let open_only = TestMind::seeded();
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ const ENV_SCRUB_VARIABLE: &str = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
|
|||
/// Optional, set by the unit: `off` turns that scrub off for a run whose user holds root, where it
|
||||
/// protects nothing and its sandbox's no_new_privs would only stop `sudo`. Anything else keeps it on.
|
||||
const SCRUB_VARIABLE: &str = "MIND_SELF_SCRUB";
|
||||
/// The variable the child's `huginn` reads its `--session` from: the run's label, the id the start message names.
|
||||
const SESSION_VARIABLE: &str = "HUGINN_SESSION";
|
||||
/// The name the start message tells her to admit as.
|
||||
pub const AGENT: &str = "mind-self-run";
|
||||
/// The namespace of the session id: the run id is the name in it.
|
||||
|
|
@ -227,6 +229,12 @@ fn flush_text(why: Why) -> String {
|
|||
format!("{cause} {FLUSH_MESSAGE} Close each brief that is done by the closing rule; a brief whose change is not on its default branch stays open, or is superseded by your own follow-up when it waits on someone else, and the next session continues it.")
|
||||
}
|
||||
|
||||
/// The label of a run, the last segment of its id (`<instance>:run:<label>`): what the opening receipt of every run already carries as its session
|
||||
/// (`launch::run_label`), so a `huginn` admission and the run's own receipts name one session in one spelling.
|
||||
fn run_label(run: &PipelineRef) -> &str {
|
||||
run.id.0.rsplit(':').next().unwrap_or(&run.id.0)
|
||||
}
|
||||
|
||||
/// The session id of a run: UUIDv5 over the run's id. Derived, never stored.
|
||||
pub fn session_id(run: &PipelineRef) -> Uuid {
|
||||
Uuid::new_v5(&SESSION_NAMESPACE, run.id.0.as_bytes())
|
||||
|
|
@ -464,8 +472,10 @@ pub struct Kept {
|
|||
}
|
||||
|
||||
pub trait Spawner {
|
||||
/// Starts the child with `token` as its credential descriptor.
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>>;
|
||||
/// Starts the child with `token` as its credential descriptor and, when `session` is given, that label
|
||||
/// as its `HUGINN_SESSION`: the session its `huginn` admissions are attributed to. A child with no run
|
||||
/// label (the Persona's) is given none.
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>>;
|
||||
|
||||
/// The organ's one kept conversation, if any. A spawner that keeps nothing says none.
|
||||
fn kept(&self) -> Option<Kept> {
|
||||
|
|
@ -728,7 +738,7 @@ fn lend_and_drive(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, s
|
|||
heard.resumed = Some(kept.session);
|
||||
writeln!(out, "session resumed {} warm until {}", kept.session, kept.warm_until.to_rfc3339_opts(SecondsFormat::Secs, true))?;
|
||||
}
|
||||
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token())?;
|
||||
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token(), Some(run_label(run)))?;
|
||||
let mut inbox = SelfInbox::new(mind, (run, claims), task, (lease.plan_quota(), started_ms), (told, conversation), heard, resume.is_some());
|
||||
let ran = drive(clock, child, &mut inbox, run_cap_s, out);
|
||||
inbox.admit_unadmitted();
|
||||
|
|
@ -815,6 +825,7 @@ impl<'a> SelfInbox<'a> {
|
|||
fn reply_to(&mut self, line: &str, ok: bool) {
|
||||
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return };
|
||||
let text = result_text(&event);
|
||||
let text = text.as_str();
|
||||
let flush = std::mem::take(&mut self.flushing);
|
||||
// The flush result is the session's note to its successor, in the journal: it is no reply.
|
||||
if ok && !flush && !text.trim().is_empty() {
|
||||
|
|
@ -913,8 +924,109 @@ fn result_head(event: &serde_json::Map<String, Value>) -> String {
|
|||
head
|
||||
}
|
||||
|
||||
fn result_text(event: &serde_json::Map<String, Value>) -> &str {
|
||||
event.get("result").and_then(Value::as_str).unwrap_or("")
|
||||
/// A result's words as they leave the actuator, for the journal and for her mind (the reply): redacted by `redact_url_userinfo`.
|
||||
fn result_text(event: &serde_json::Map<String, Value>) -> String {
|
||||
redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The one redaction of URL credentials. Call it at each place a string the CLI produced enters the journal, her mind or chat: today
|
||||
/// the Self result (`result_text`: journal line and Reply) and the structured output of a Persona or Sleep result
|
||||
/// (`persona::structured_output`, string by string). It does not cover claude's stderr (inherited) or the CLI's transcript files.
|
||||
///
|
||||
/// A forge token rides in a remote URL, so every `scheme://` and every protocol-relative `//` opens an authority, read as git, curl and
|
||||
/// RFC 3986 read it: it ends at the first `/`, `?`, `#`, backslash, quote, backtick, parenthesis, bracket, angle bracket or ASCII whitespace
|
||||
/// (`is_authority_end`). Its userinfo is everything before the authority's LAST `@` (a raw `@` in a password still redacts), and non-ASCII
|
||||
/// characters, NBSP and U+2028 included, stay inside it. The userinfo becomes `<redacted>`: `scheme://<redacted>@host`. With a colon it is
|
||||
/// always redacted; without one it is a bare token on http, https, ftp, ftps, ws, wss, any `+http`/`+https` scheme (`git+https`), protocol-relative `//` and an unparseable scheme, and a plain
|
||||
/// username on any other scheme (`ssh://git@host`), which stays. A `@` after the authority (`github.com/@user`, `pkg@1.2.3`) is path, not
|
||||
/// userinfo, and is left alone. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of an
|
||||
/// ASCII-whitespace-delimited word. Not covered: a password holding `/ ? # ` a quote or ASCII whitespace (no parser reads it as userinfo),
|
||||
/// scp-style `user:secret@host:path`, tokens in query strings, claude's stderr, the kept transcripts. Syntax only; no reading of meaning.
|
||||
pub(crate) fn redact_url_userinfo(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find("//") {
|
||||
let (before, after) = rest.split_at(at);
|
||||
let body = &after[2..];
|
||||
out.push_str(before);
|
||||
out.push_str("//");
|
||||
// The scheme is the ASCII run before `:`. Without a colon, `//` opens an authority only at a word start (`a//b@c` is not one).
|
||||
let scheme = match before.strip_suffix(':') {
|
||||
// Only ASCII bytes match, so the byte after the last non-match is a character boundary.
|
||||
Some(head) => head[head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1)..].to_ascii_lowercase(),
|
||||
None if before.chars().next_back().is_none_or(|c| is_break(c) || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | '<' | '=' | ',')) => String::new(),
|
||||
None => {
|
||||
rest = body;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let end = body.find(is_authority_end).unwrap_or(body.len());
|
||||
let authority = &body[..end];
|
||||
match authority.rfind('@') {
|
||||
Some(i) if i > 0 && (authority[..i].contains(':') || carries_bare_token(&scheme)) => {
|
||||
out.push_str("<redacted>");
|
||||
out.push_str(&authority[i..]);
|
||||
}
|
||||
_ => out.push_str(authority),
|
||||
}
|
||||
rest = &body[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
redact_schemeless(&out)
|
||||
}
|
||||
|
||||
/// Schemes whose lone userinfo is a token, not a username: web and ftp schemes, `ws`/`wss`, and `x+http`/`x+https` wrappers; `scheme` is lowercase.
|
||||
fn carries_bare_token(scheme: &str) -> bool {
|
||||
matches!(scheme, "" | "http" | "https" | "ftp" | "ftps" | "ws" | "wss") || scheme.ends_with("+http") || scheme.ends_with("+https")
|
||||
}
|
||||
|
||||
/// ASCII whitespace; NBSP, U+2028 and other Unicode whitespace are not, so a secret holding them is redacted whole.
|
||||
fn is_break(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
}
|
||||
|
||||
/// The end of a URL's authority.
|
||||
fn is_authority_end(c: char) -> bool {
|
||||
is_break(c) || matches!(c, '/' | '?' | '#' | '\\' | '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>')
|
||||
}
|
||||
|
||||
/// `word:secret@host.tld/` at the start of a whitespace-delimited word that holds no `//` becomes `<redacted>@host.tld/`.
|
||||
fn redact_schemeless(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut word = String::new();
|
||||
let flush = |word: &mut String, out: &mut String| {
|
||||
out.push_str(&redact_word(word));
|
||||
word.clear();
|
||||
};
|
||||
for c in text.chars() {
|
||||
if is_break(c) {
|
||||
flush(&mut word, &mut out);
|
||||
out.push(c);
|
||||
} else {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
flush(&mut word, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_word(word: &str) -> String {
|
||||
let shaped = (|| {
|
||||
if word.contains("//") {
|
||||
return None;
|
||||
}
|
||||
let at = word.rfind('@')?;
|
||||
let colon = word.find(':')?;
|
||||
let (user, host) = (&word[..colon], &word[at + 1..]);
|
||||
let host_name = &host[..host.find('/')?];
|
||||
let user_ok = !user.is_empty() && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
|
||||
let host_ok = host_name.contains('.') && host_name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'));
|
||||
(colon + 1 < at && user_ok && host_ok).then_some(at)
|
||||
})();
|
||||
match shaped {
|
||||
Some(at) => format!("<redacted>{}", &word[at..]),
|
||||
None => word.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in consecutive pieces of at most `limit` bytes, each cut at a character boundary; their concatenation is `text`.
|
||||
|
|
@ -1090,13 +1202,13 @@ impl Spawner for ClaudeSpawner {
|
|||
clear_kept_dir(dir, &[KEPT_RECORD.to_string(), format!("{session}.jsonl")]).context("the conversation before it could not be removed")
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
let config = FreshConfig::create(&self.scratch, self.seed.as_deref(), self.keep.clone())?;
|
||||
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, &self.unit_environment, config) };
|
||||
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, (&self.unit_environment, session), config) };
|
||||
let mut wrapped = sandbox_args(sandbox, &self.scratch);
|
||||
wrapped.push("claude".into());
|
||||
wrapped.extend(args.iter().cloned());
|
||||
spawn_program(Path::new("bwrap"), &wrapped, token, &self.unit_environment, config)
|
||||
spawn_program(Path::new("bwrap"), &wrapped, token, (&self.unit_environment, session), config)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1274,6 +1386,7 @@ fn copy_level(root: &Path, from: &Path, to: &Path) -> std::io::Result<()> {
|
|||
/// `CLAUDE_CONFIG_DIR` are its fresh configuration directory, set by the spawn.
|
||||
/// `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` are the `huginn` CLI's whole identity
|
||||
/// (ruling `eureka-body:ruling:cli-over-mcp-agent-surface`): a name and an address, no secret.
|
||||
/// Its session, `HUGINN_SESSION`, is set by the spawn from the run, never passed through.
|
||||
fn child_may_see(name: &OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
matches!(name.as_ref(), "PATH" | "LANG" | "TERM" | "EUREKA_INSTANCE" | "HUGINN_ENDPOINT") || name.starts_with("LC_")
|
||||
|
|
@ -1295,13 +1408,17 @@ fn scrub_is_off(unit: &[(OsString, OsString)]) -> bool {
|
|||
/// token. `claude` reads it to the end once at startup, so what its own children
|
||||
/// inherit is an emptied pipe whose write end exists nowhere. The child's
|
||||
/// environment is cleared and rebuilt from `unit`'s allow-listed variables.
|
||||
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, unit: &[(OsString, OsString)], config: FreshConfig) -> Result<Box<dyn Child>> {
|
||||
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, (unit, session): (&[(OsString, OsString)], Option<&str>), config: FreshConfig) -> Result<Box<dyn Child>> {
|
||||
let inheritable = token.inheritable().context("the credential descriptor could not be placed")?;
|
||||
let mut command = Command::new(program);
|
||||
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
|
||||
command.env_clear().envs(child_environment(unit));
|
||||
command.env("HOME", &config.dir).env(CONFIG_DIR_VARIABLE, &config.dir);
|
||||
command.env(DISABLE_COMPACT_VARIABLE, "1").env(TOKEN_FD_VARIABLE, inheritable.number().to_string()).env(ENV_SCRUB_VARIABLE, if scrub_is_off(unit) { "0" } else { "1" });
|
||||
// The unit's own `HUGINN_SESSION` is not on the allow-list: the only value the child can hold is the one its run was given.
|
||||
if let Some(session) = session {
|
||||
command.env(SESSION_VARIABLE, session);
|
||||
}
|
||||
// Its own process group, so that a signal reaches what the child started too.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ struct Ran {
|
|||
/// Every event, the lease's included.
|
||||
all: Vec<Event>,
|
||||
spawns: Vec<Vec<String>>,
|
||||
sessions: Vec<Option<String>>,
|
||||
admits: usize,
|
||||
asked: Vec<LeaseRequest>,
|
||||
}
|
||||
|
|
@ -215,7 +216,8 @@ fn carry_kept(seen: &Seen, steps: Vec<Step>, (code, dies_on, refuses, hangs): (O
|
|||
// The child's own events; the lease's are in `all`, in order with them.
|
||||
let events = all.iter().filter(|event| !matches!(event, Event::Lent | Event::Spawned | Event::Abandoned | Event::Kept(_))).cloned().collect();
|
||||
let spawns = fake.spawns.borrow().clone();
|
||||
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
|
||||
let sessions = fake.sessions.borrow().clone();
|
||||
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, sessions, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
|
||||
}
|
||||
|
||||
fn at_start() -> chrono::DateTime<chrono::Utc> {
|
||||
|
|
@ -363,6 +365,26 @@ fn the_child_is_started_for_her_run_with_the_fixed_list_whatever_the_run_record_
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_child_is_given_its_runs_label_as_huginn_session_fresh_resumed_and_in_the_next_run_only() {
|
||||
// Fresh: the bare label, the spelling the run's opening receipt carries, not the id and not the derived UUID.
|
||||
let a = with_run();
|
||||
let fresh = carry_with(&Seen::of(&a), vec![Out(result_line())], Some(0), false);
|
||||
assert_eq!(fresh.sessions, [Some("selfrun-a".to_string())]);
|
||||
// Resumed: the same child, the new run's label.
|
||||
let resumed = carry_kept(&Seen::of(&with_run()), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
|
||||
assert_eq!(resumed.sessions, [Some("selfrun-a".to_string())]);
|
||||
// The rotation's new run sets its own; nothing of the previous run is carried.
|
||||
let b = TestMind::seeded();
|
||||
b.committed(vec![self_run("selfrun-b", "5")]);
|
||||
let next = carry_kept(&Seen::of(&b), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
|
||||
assert_eq!(next.sessions, [Some("selfrun-b".to_string())]);
|
||||
for ran in [&fresh, &resumed, &next] {
|
||||
assert!(ran.sessions.iter().flatten().all(|label| !label.contains(':') && label.parse::<Uuid>().is_err()), "{:?}", ran.sessions);
|
||||
}
|
||||
assert!(!next.sessions.contains(&Some("selfrun-a".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_message_is_the_first_thing_written_and_names_the_skill_the_instance_and_the_run() {
|
||||
let ran = go(vec![Out(result_line())]);
|
||||
|
|
@ -962,7 +984,7 @@ mod process {
|
|||
fn start_with(program: &Path, args: &[String], token: &TokenFd) -> Box<dyn Child> {
|
||||
for _ in 0..50 {
|
||||
let config = FreshConfig::create(program.parent().unwrap(), None, None).unwrap();
|
||||
match spawn_program(program, args, token, &unit_environment(), config) {
|
||||
match spawn_program(program, args, token, (&unit_environment(), None), config) {
|
||||
Ok(child) => return child,
|
||||
Err(error) if format!("{error:#}").contains("busy") => std::thread::sleep(StdDuration::from_millis(100)),
|
||||
Err(error) => panic!("{error:#}"),
|
||||
|
|
@ -1034,7 +1056,7 @@ mod process {
|
|||
let canary = "canary-argument-6b0d";
|
||||
let scratch = tempfile::tempdir().unwrap();
|
||||
let config = FreshConfig::create(scratch.path(), None, None).unwrap();
|
||||
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), &[], config).err().unwrap();
|
||||
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), (&[], None), config).err().unwrap();
|
||||
assert!(!format!("{error:#}").contains(canary) && !format!("{error:#}").contains("nonexistent"), "{error:#}");
|
||||
}
|
||||
|
||||
|
|
@ -1061,7 +1083,7 @@ mod process {
|
|||
|
||||
struct Spawn(PathBuf);
|
||||
impl Spawner for Spawn {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
Ok(Box::new(Prompt(start_with(&self.0, args, token))))
|
||||
}
|
||||
}
|
||||
|
|
@ -1087,7 +1109,7 @@ mod process {
|
|||
|
||||
struct Patient(PathBuf);
|
||||
impl Spawner for Patient {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
Ok(Box::new(Bounded(start_with(&self.0, args, token), std::time::Instant::now())))
|
||||
}
|
||||
}
|
||||
|
|
@ -1942,7 +1964,7 @@ mod sandbox {
|
|||
unit.push(("PATH".into(), format!("{}:/usr/bin:/bin", bin.display()).into()));
|
||||
unit.push((SCRATCH_VARIABLE.into(), scratch.clone().into_os_string()));
|
||||
let spawner = ClaudeSpawner::in_sandbox(unit, "MIND_PERSONA_CONFIG_SEED", Some(sandbox.clone())).unwrap();
|
||||
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary")).unwrap();
|
||||
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary"), None).unwrap();
|
||||
while !matches!(child.next(StdDuration::from_secs(20)), Read::Exited(_) | Read::Timeout) {}
|
||||
let recorded: Vec<String> = std::fs::read_to_string(&argv).unwrap().lines().map(String::from).collect();
|
||||
let mut expected = sandbox_args(&sandbox, &scratch);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ echo left > "$CLAUDE_CONFIG_DIR/leftover"
|
|||
|
||||
fn run_once(program: &Path, scratch: &Path, seed: Option<&Path>) -> Vec<String> {
|
||||
let config = FreshConfig::create(scratch, seed, None).unwrap();
|
||||
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), &unit_environment(), config).unwrap();
|
||||
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), (&unit_environment(), None), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, code) = drain(&mut *child);
|
||||
assert_eq!(code, Some(0), "{lines:?}");
|
||||
|
|
@ -167,7 +167,7 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
|
|||
let mut unit = unit_environment();
|
||||
unit.extend(value.map(|value| (SCRUB_VARIABLE.into(), value.into())));
|
||||
let config = FreshConfig::create(dir.path(), None, None).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), &unit, config).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, None), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, _) = drain(&mut *child);
|
||||
field(&lines, "scrub")
|
||||
|
|
@ -177,3 +177,22 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
|
|||
assert_eq!(scrub_with(Some("OFF")), "1", "only the exact word turns it off");
|
||||
assert_eq!(scrub_with(Some("off")), "0", "a unit for a user that holds root turns it off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_child_holds_huginn_session_only_as_the_spawn_set_it_never_as_the_unit_had_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let program = script(&dir, "echo \"session=${HUGINN_SESSION-unset}\"");
|
||||
let session_of = |unit_value: Option<&str>, given: Option<&str>| {
|
||||
let mut unit = unit_environment();
|
||||
unit.extend(unit_value.map(|value| ("HUGINN_SESSION".into(), value.into())));
|
||||
let config = FreshConfig::create(dir.path(), None, None).unwrap();
|
||||
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, given), config).unwrap();
|
||||
child.close_input();
|
||||
let (lines, _) = drain(&mut *child);
|
||||
field(&lines, "session")
|
||||
};
|
||||
assert_eq!(session_of(None, Some("mind-20261111T072944197Z")), "mind-20261111T072944197Z");
|
||||
assert_eq!(session_of(None, None), "unset", "a child with no run label is given none");
|
||||
assert_eq!(session_of(Some("stale-unit-value"), None), "unset", "the unit's own value is not on the allow-list");
|
||||
assert_eq!(session_of(Some("stale-unit-value"), Some("mind-new")), "mind-new", "the run's label is the only value");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,3 +118,110 @@ fn a_refused_piece_of_a_long_result_holds_back_the_pieces_behind_it() {
|
|||
let ordinals: Vec<u64> = (1..=3).map(|n| mind.view(K::Reply, &format!("{INSTANCE}:reply:selfrun-a-r{n}")).unwrap_or_else(|| panic!("piece {n} lost")).admission.ordinal).collect();
|
||||
assert!(ordinals.windows(2).all(|w| w[0] < w[1]), "the pieces reached the mind out of order: {ordinals:?}");
|
||||
}
|
||||
|
||||
// A canary stands where a forge token would: no byte of it may reach the journal or her mind.
|
||||
const CANARY: &str = "CANARY-NOT-A-REAL-TOKEN-0000";
|
||||
|
||||
#[test]
|
||||
fn url_userinfo_is_redacted_where_it_is_a_secret_and_kept_where_it_is_a_username() {
|
||||
let said = format!(
|
||||
"a https://user:{CANARY}@host.example/r.git b https://{CANARY}@host.example/r.git c ssh://u:{CANARY}@h/x d http://{CANARY}@h e HTTPS://x:{CANARY}@h \
|
||||
f https://u:p@ss{CANARY}@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://u:{CANARY}@h) k ://@x"
|
||||
);
|
||||
let want = "a https://<redacted>@host.example/r.git b https://<redacted>@host.example/r.git c ssh://<redacted>@h/x d http://<redacted>@h e HTTPS://<redacted>@h \
|
||||
f https://<redacted>@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://<redacted>@h) k ://@x";
|
||||
assert_eq!(redact_url_userinfo(&said), want);
|
||||
assert!(!redact_url_userinfo(&said).contains(CANARY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_in_a_url_reaches_neither_the_journal_nor_the_admitted_reply() {
|
||||
let mind = live_mind();
|
||||
let said = format!("origin https://user:{CANARY}@host.example/r.git and https://{CANARY}@host.example/s.git");
|
||||
let ran = mind_run(&mind, vec![Out(echo()), Out(saying(&said))]);
|
||||
assert!(ran.outcome.is_ok());
|
||||
assert!(!ran.out.contains(CANARY), "{}", ran.out);
|
||||
assert!(ran.out.contains("https://<redacted>@host.example/r.git"), "{}", ran.out);
|
||||
let text = text_of(&mind, 1);
|
||||
assert!(!text.contains(CANARY), "{text}");
|
||||
assert_eq!(text, "origin https://<redacted>@host.example/r.git and https://<redacted>@host.example/s.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() {
|
||||
let mind = live_mind();
|
||||
let said = format!("{}https://u:{CANARY}@h/x{}", "a".repeat(REPLY_TEXT_LIMIT - 25), "b".repeat(100));
|
||||
mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap();
|
||||
let first = reply_of(&mind, 1).expect("the long result was admitted in pieces");
|
||||
assert!(reply_of(&mind, 2).is_some(), "the credential's place straddles the cut");
|
||||
assert!(!first.text.0.contains("CANARY") && !reply_of(&mind, 2).unwrap().text.0.contains("CANARY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_left_alone() {
|
||||
let r = redact_url_userinfo;
|
||||
// Raw @, percent-encoding and non-ASCII (NBSP, U+2028) stay inside userinfo.
|
||||
for secret in ["pa@ssT", "pa%40ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa:ssT", "\u{e9}\u{65e5}ssT"] {
|
||||
let out = r(&format!("x https://u:{secret}@h/x"));
|
||||
assert_eq!(out, "x https://<redacted>@h/x", "{secret:?}");
|
||||
}
|
||||
// The authority ends at / ? # \\ quotes, brackets and ASCII whitespace: no parser reads those as userinfo, so what follows is path.
|
||||
assert_eq!(r("https://u:pa/ssT@h/x"), "https://u:pa/ssT@h/x");
|
||||
assert_eq!(r("https://u:pa ssT@h/x"), "https://u:pa ssT@h/x");
|
||||
assert_eq!(r("https://u:pa?ssT@h"), "https://u:pa?ssT@h");
|
||||
// Ordinary URLs with @ in the path are byte-identical.
|
||||
for same in [
|
||||
"https://github.com/@user/repo",
|
||||
"https://medium.com/@name/post-1",
|
||||
"https://registry.npmjs.org/@scope/pkg",
|
||||
"https://h/pkg@1.2.3",
|
||||
"a//b@c",
|
||||
"see //cdn.h/@x/y and https://h/?mail=a@b.c#f@g",
|
||||
"https://h:8080/p git@github.com:org/r.git ssh://git@host/r.git ssh://git@h:22/r git://git@h/x",
|
||||
] {
|
||||
assert_eq!(r(same), same);
|
||||
}
|
||||
// Compact JSON: only the credentialed URL changes.
|
||||
let json = format!(r#"{{"a":"https://u:{CANARY}@h/x","b":"https://h2/@v","c":"https://h3/p@1"}}"#);
|
||||
assert_eq!(r(&json), r#"{"a":"https://<redacted>@h/x","b":"https://h2/@v","c":"https://h3/p@1"}"#);
|
||||
assert_eq!(r("a/https://u:T@h/x,https://v:T@h2"), "a/https://<redacted>@h/x,https://<redacted>@h2");
|
||||
assert_eq!(r("HTTPS://TOKEN@h"), "HTTPS://<redacted>@h");
|
||||
assert_eq!(r("[l](https://T@h/x) <https://T@h> `https://u:T@h`"), "[l](https://<redacted>@h/x) <https://<redacted>@h> `https://<redacted>@h`");
|
||||
assert_eq!(r("a //u:T@host/x b //T@host c \"//T@h\" (//T@h)"), "a //<redacted>@host/x b //<redacted>@host c \"//<redacted>@h\" (//<redacted>@h)");
|
||||
// Token-bearing schemes beyond http: +http/+https wrappers and ws/wss redact a bare token; git+ssh and ssh keep their username.
|
||||
assert_eq!(r("git+https://T@github.com/a/b GIT+HTTPS://T@h wss://T@h/ ws://T@h/"), "git+https://<redacted>@github.com/a/b GIT+HTTPS://<redacted>@h wss://<redacted>@h/ ws://<redacted>@h/");
|
||||
for same in ["git+ssh://git@host/x", "ssh://git@host/x"] {
|
||||
assert_eq!(r(same), same);
|
||||
}
|
||||
// Scheme-less: only word:secret@host.tld/ . Each guard of the shape has its own case.
|
||||
assert_eq!(r("user:T@host.tld/path"), "<redacted>@host.tld/path");
|
||||
for same in ["a:b@c", "a:b@host/x", "a:b@ho_st.tld/x", "u$er:T@host.tld/x", "a:@host.tld/x", "mail me@x.org/ and :T@host.tld/x"] {
|
||||
assert_eq!(r(same), same);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_text_never_panics_the_redaction() {
|
||||
let r = redact_url_userinfo;
|
||||
for case in ["see \u{201c}https://h/x", "\u{2192}https://h/x", "docs\u{ff1a}https://h/x", "\u{e9}://x", "\u{65e5}\u{672c}://u:T@h", "https://x/\u{e9}://", "\u{e9}//u:T@h", "\u{e9}:u:T@h.tld/"] {
|
||||
let _ = r(case);
|
||||
}
|
||||
assert_eq!(r("\u{65e5}\u{672c}://u:T@h"), "\u{65e5}\u{672c}://<redacted>@h");
|
||||
assert_eq!(r("\u{2192}https://u:T@h/x"), "\u{2192}https://<redacted>@h/x");
|
||||
let seed = "\u{201c}\u{2192}\u{ff1a}\u{e9}\u{65e5}\u{a0}https://u:T@h/x//y:z@a.b/ ://@ //";
|
||||
let chars: Vec<char> = seed.chars().collect();
|
||||
for start in 0..=chars.len() {
|
||||
for end in start..=chars.len() {
|
||||
let _ = r(&chars[start..end].iter().collect::<String>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_and_sleep_structured_output_is_redacted_string_by_string() {
|
||||
let line = json!({"type": "result", "structured_output": {"say": format!("https://u:{CANARY}@h/x,"), "n": 3, "list": [format!("https://{CANARY}@h")]}}).to_string();
|
||||
let text = crate::persona::structured_output(&line).unwrap();
|
||||
assert!(!text.contains(CANARY), "{text}");
|
||||
let back: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back, json!({"say": "https://<redacted>@h/x,", "n": 3, "list": ["https://<redacted>@h"]}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fn previous() -> Uuid {
|
|||
Uuid::parse_str(PREVIOUS).unwrap()
|
||||
}
|
||||
|
||||
fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
|
||||
pub(super) fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
|
||||
Kept { session: previous(), transcript: PathBuf::from(format!("/state/session/{PREVIOUS}.jsonl")), warm_until: at_start() + ChronoDuration::seconds(warm_for_s), written_at: at_start(), same_seed }
|
||||
}
|
||||
|
||||
|
|
@ -321,8 +321,8 @@ impl Spawner for FilesThenDies {
|
|||
self.files.keep(keep)
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
self.dies.spawn(args, token)
|
||||
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
self.dies.spawn(args, token, session)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//! and never starts a run. Delivery to a Persona is another organ's.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
|
|
@ -25,7 +25,9 @@ use eureka_pipeline::{
|
|||
use eureka_state::HuginnClient;
|
||||
use ghostlight_persona_projection::{ProjectorPrompt, build_projector_prompt};
|
||||
use huginn_mind::{HuginnMindResponse, PipelineAdmissionOutcome, PipelineDocumentView, PipelinePageItems};
|
||||
use model_connector::{ConnectorClient, InputItem, ProviderRequest, ReasoningEffort, StopReason, TransportDisposition, TransportEventPayload, TransportInvocation, TransportOutcome};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::control::Effective;
|
||||
use crate::persona::{Responsibilities, outcome_words, quoted, recent_runs, render_responsibilities, repo_notice_line};
|
||||
|
|
@ -95,25 +97,31 @@ impl Sampling {
|
|||
const STREAM: Self = Self { temperature_permille: TEMPERATURE_PERMILLE, min_p_permille: MIN_P_PERMILLE, max_tokens: MAX_TOKENS };
|
||||
}
|
||||
|
||||
/// The two models of a tick. Faults are errors: the tick ends, the process does not.
|
||||
pub trait StreamModel {
|
||||
/// The Projector: one chat prompt in, one projection out.
|
||||
fn project(&self, prompt: &str) -> Result<String>;
|
||||
/// The continuer: `n` raw continuations of `prompt`, no chat template.
|
||||
/// The Projector: state to narrative. Its one owner is `KitProjector`, her own caller on the connector. A fault is an error:
|
||||
/// the tick ends, the process does not.
|
||||
pub trait Projector {
|
||||
/// The model id every projection of this Projector names; the thought records it, so it is the model that ran.
|
||||
fn model(&self) -> &str;
|
||||
/// One turn: `prompt` is the turn's one user text and `request_id` is its identity on the connector.
|
||||
fn project(&self, request_id: &str, prompt: &str) -> Result<String>;
|
||||
}
|
||||
|
||||
/// The continuer: raw continuation of a frame. Its one owner is `OllamaContinuer`, the base model on this host.
|
||||
pub trait Continuer {
|
||||
/// `n` raw continuations of `prompt`, no chat template.
|
||||
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>>;
|
||||
}
|
||||
|
||||
/// Ollama on this host. Loopback only, as Ghostlight's local binding is.
|
||||
pub struct OllamaStream {
|
||||
/// The base model on Ollama on this host. Loopback only, as Ghostlight's local binding is.
|
||||
pub struct OllamaContinuer {
|
||||
base: String,
|
||||
projector_model: String,
|
||||
base_model: String,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl OllamaStream {
|
||||
impl OllamaContinuer {
|
||||
/// `endpoint` is `http://<loopback host>:<port>`; any other address is refused here.
|
||||
pub fn open(endpoint: &str, projector_model: &str, base_model: &str) -> Result<Self> {
|
||||
pub fn open(endpoint: &str, base_model: &str) -> Result<Self> {
|
||||
// Parsed as the HTTP client parses it, so the host checked is the host connected to.
|
||||
let uri: ureq::http::Uri = endpoint.parse().map_err(|_| anyhow!("the stream endpoint is not a URL"))?;
|
||||
let authority = uri.authority().filter(|_| uri.scheme_str() == Some("http")).ok_or_else(|| anyhow!("the stream endpoint must be http://<loopback>:<port>"))?;
|
||||
|
|
@ -137,7 +145,7 @@ impl OllamaStream {
|
|||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
Ok(Self { base: format!("http://{authority}"), projector_model: projector_model.to_owned(), base_model: base_model.to_owned(), agent })
|
||||
Ok(Self { base: format!("http://{authority}"), base_model: base_model.to_owned(), agent })
|
||||
}
|
||||
|
||||
fn post(&self, path: &str, body: &Value) -> Result<Value> {
|
||||
|
|
@ -152,12 +160,7 @@ impl OllamaStream {
|
|||
}
|
||||
}
|
||||
|
||||
impl StreamModel for OllamaStream {
|
||||
fn project(&self, prompt: &str) -> Result<String> {
|
||||
let answer = self.post("/api/chat", &json!({ "model": self.projector_model, "stream": false, "messages": [{ "role": "user", "content": prompt }] }))?;
|
||||
answer["message"]["content"].as_str().map(str::to_owned).ok_or_else(|| anyhow!("the Projector's answer has no message content"))
|
||||
}
|
||||
|
||||
impl Continuer for OllamaContinuer {
|
||||
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>> {
|
||||
let body = json!({
|
||||
"model": self.base_model,
|
||||
|
|
@ -178,6 +181,82 @@ impl StreamModel for OllamaStream {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The Projector: her own caller on the connector.
|
||||
|
||||
/// What the connector's request asks of the model beside the user text. It names no membrane text: that is Ghostlight's.
|
||||
const PROJECTOR_INSTRUCTIONS: &str = "Write only the text the request asks for.";
|
||||
/// Room for the model's adaptive thinking; the projection itself is at most `PROJECTOR_WORDS` words.
|
||||
const PROJECTOR_MAX_OUTPUT_TOKENS: u32 = 1024;
|
||||
/// The sealed frame the client will carry: the prompt out and the projection back fit with wide room.
|
||||
const CONNECTOR_FRAME_BYTES: usize = 256 * 1024;
|
||||
/// How long an invocation stays admissible at the connector.
|
||||
const INVOCATION_LIFE_MS: u64 = 60_000;
|
||||
|
||||
/// The identity of one projection on the connector. A live tick's is derived from the instance and the tick label alone; a
|
||||
/// dry run's also carries the moment, because the kit's replay store answers a repeated id byte-identically.
|
||||
fn request_id(instance: &Slug, label: &str, mode: Mode, now: DateTime<Utc>) -> String {
|
||||
match mode {
|
||||
Mode::Live => format!("{AGENT}:{}:{label}", instance.0),
|
||||
Mode::DryRun => format!("{AGENT}:{}:{label}:dry{}", instance.0, now.timestamp_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The connector key: the credential file in systemd's credentials directory, read once and trimmed. Its error names the
|
||||
/// credential and never a byte of the file.
|
||||
pub fn connector_key(credentials: &std::path::Path) -> Result<String> {
|
||||
let key = std::fs::read_to_string(credentials.join("connector-key")).map_err(|_| anyhow!("the connector-key credential could not be read"))?;
|
||||
Ok(key.trim().to_owned())
|
||||
}
|
||||
|
||||
/// The Projector as one tool-less connector turn by `caller` (her instance) on `model`. Loopback only, as the kit is.
|
||||
/// Each projection is its own conversation: nothing is carried from one tick to the next.
|
||||
pub struct KitProjector {
|
||||
client: ConnectorClient,
|
||||
caller: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl KitProjector {
|
||||
/// `endpoint` is the kit's loopback address; `connection_key` is the caller's key, read by the binary and never printed.
|
||||
pub fn open(endpoint: SocketAddr, connection_key: String, caller: &str, model: &str, timeout: Duration) -> Result<Self> {
|
||||
if !endpoint.ip().is_loopback() {
|
||||
bail!("the connector endpoint is not a loopback address");
|
||||
}
|
||||
let client = ConnectorClient::new(endpoint, connection_key, CONNECTOR_FRAME_BYTES, Some(timeout)).map_err(|_| anyhow!("the connector client could not be configured"))?;
|
||||
Ok(Self { client, caller: caller.to_owned(), model: model.to_owned() })
|
||||
}
|
||||
}
|
||||
|
||||
impl Projector for KitProjector {
|
||||
fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn project(&self, request_id: &str, prompt: &str) -> Result<String> {
|
||||
let mut request = ProviderRequest::new(request_id, request_id, &self.model, PROJECTOR_INSTRUCTIONS, PROJECTOR_MAX_OUTPUT_TOKENS);
|
||||
request.input = vec![InputItem::UserText { text: prompt.to_owned() }];
|
||||
request.reasoning_effort = Some(ReasoningEffort::Low);
|
||||
let expires = u64::try_from(Utc::now().timestamp_millis()).unwrap_or_default() + INVOCATION_LIFE_MS;
|
||||
let invocation = TransportInvocation::new(&self.caller, expires, Sha256::digest(prompt.as_bytes()).into(), request).map_err(|_| anyhow!("the Projector's request is not a valid connector request"))?;
|
||||
let result = self.client.execute(&invocation).map_err(|fault| anyhow!("the connector did not answer the Projector: {fault}"))?;
|
||||
match result.disposition {
|
||||
TransportDisposition::Refused(reason) => bail!("the connector refused the Projector: {reason:?}"),
|
||||
TransportDisposition::Transported { events, receipt } => match receipt.outcome {
|
||||
TransportOutcome::Completed { stop_reason: StopReason::EndTurn, .. } => Ok(events
|
||||
.into_iter()
|
||||
.filter_map(|event| match event.payload {
|
||||
TransportEventPayload::TextDelta { text } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect()),
|
||||
TransportOutcome::Completed { stop_reason, .. } => bail!("the Projector's turn ended early: {stop_reason:?}"),
|
||||
TransportOutcome::Failed { kind, .. } => bail!("the provider failed the Projector: {kind:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Her persona state, read and never put.
|
||||
|
||||
|
|
@ -526,8 +605,8 @@ pub enum Mode {
|
|||
pub struct Organs<'a> {
|
||||
pub mind: &'a dyn MindPort,
|
||||
pub persona: &'a dyn PersonaSource,
|
||||
pub model: &'a dyn StreamModel,
|
||||
pub projector_model: &'a str,
|
||||
pub projector: &'a dyn Projector,
|
||||
pub continuer: &'a dyn Continuer,
|
||||
pub base_model: &'a str,
|
||||
}
|
||||
|
||||
|
|
@ -586,8 +665,9 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
|
|||
let persona = organs.persona.persona()?;
|
||||
let seed = build_seed(mind, &persona, since, now, &responsibilities, fresh)?;
|
||||
|
||||
let label = format!("t{}", now.timestamp());
|
||||
let began = std::time::Instant::now();
|
||||
let projection = bounded(&organs.model.project(&seed.projector_prompt())?, PARA_MAX_BYTES);
|
||||
let projection = bounded(&organs.projector.project(&request_id(mind.instance(), &label, mode, now), &seed.projector_prompt())?, PARA_MAX_BYTES);
|
||||
let projector_ms = u32::try_from(began.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
if projection.is_empty() {
|
||||
return Ok(Outcome::Blank);
|
||||
|
|
@ -595,7 +675,7 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
|
|||
let frame = continuer_frame(text_of(&persona.state, &["publicName"]), &now.format(FRAME_DATE_FORMAT).to_string(), &projection);
|
||||
let began = std::time::Instant::now();
|
||||
let candidates: Vec<String> = organs
|
||||
.model
|
||||
.continuer
|
||||
.continue_raw(&frame, &Sampling::STREAM, CONTINUATIONS)?
|
||||
.iter()
|
||||
.map(|text| bounded(text, LINE_MAX_BYTES))
|
||||
|
|
@ -610,14 +690,14 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
|
|||
let chosen = least_near(&nearest_permille);
|
||||
let thought = PipelineThought {
|
||||
instance: mind.instance().clone(),
|
||||
label: Label::from(format!("t{}", now.timestamp()).as_str()),
|
||||
label: Label::from(label.as_str()),
|
||||
text: Para::from(candidates[chosen].as_str()),
|
||||
reads: seed.reads.clone(),
|
||||
persona_receipt: Short(persona.receipt_id.clone()),
|
||||
identity: Line::from(seed.identity.as_str()),
|
||||
context: Para::from(seed.context.as_str()),
|
||||
stimulus: Para::from(seed.stimulus.as_str()),
|
||||
projector_model: Short(organs.projector_model.to_owned()),
|
||||
projector_model: Short(organs.projector.model().to_owned()),
|
||||
projection: Para::from(projection.as_str()),
|
||||
base_model: Short(organs.base_model.to_owned()),
|
||||
temperature_permille: Sampling::STREAM.temperature_permille,
|
||||
|
|
@ -633,7 +713,6 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
|
|||
if mode == Mode::DryRun {
|
||||
return Ok(Outcome::Thought { thought: Box::new(thought), admitted: false });
|
||||
}
|
||||
let label = thought.label.0.clone();
|
||||
match mind.admit(AGENT, &format!("stream-{label}"), vec![PipelineDocument::Thought(thought.clone())])? {
|
||||
PipelineAdmissionOutcome::Committed { .. } => Ok(Outcome::Thought { thought: Box::new(thought), admitted: true }),
|
||||
other => Err(anyhow!("the mind did not admit the thought: {other:?}")),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ struct Fake {
|
|||
projection: String,
|
||||
candidates: Vec<String>,
|
||||
fault: bool,
|
||||
projected: RefCell<Vec<String>>,
|
||||
projected: RefCell<Vec<(String, String)>>,
|
||||
continued: RefCell<Vec<(String, Sampling, usize)>>,
|
||||
}
|
||||
|
||||
|
|
@ -72,12 +72,18 @@ impl Fake {
|
|||
}
|
||||
}
|
||||
|
||||
impl StreamModel for Fake {
|
||||
fn project(&self, prompt: &str) -> Result<String> {
|
||||
self.projected.borrow_mut().push(prompt.into());
|
||||
if self.fault { Err(anyhow!("the model host is down")) } else { Ok(self.projection.clone()) }
|
||||
impl Projector for Fake {
|
||||
fn model(&self) -> &str {
|
||||
"projector:test"
|
||||
}
|
||||
|
||||
fn project(&self, request_id: &str, prompt: &str) -> Result<String> {
|
||||
self.projected.borrow_mut().push((request_id.into(), prompt.into()));
|
||||
if self.fault { Err(anyhow!("the connector is down")) } else { Ok(self.projection.clone()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Continuer for Fake {
|
||||
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>> {
|
||||
self.continued.borrow_mut().push((prompt.into(), *sampling, n));
|
||||
Ok(self.candidates.iter().take(n).cloned().collect())
|
||||
|
|
@ -200,7 +206,7 @@ fn mind_with_thought(thought_at: i64) -> TestMind {
|
|||
}
|
||||
|
||||
fn organs<'a>(mind: &'a dyn MindPort, persona: &'a Persona, model: &'a Fake) -> Organs<'a> {
|
||||
Organs { mind, persona, model, projector_model: "projector:test", base_model: "base:test" }
|
||||
Organs { mind, persona, projector: model, continuer: model, base_model: "base:test" }
|
||||
}
|
||||
|
||||
fn thoughts(mind: &TestMind) -> Vec<String> {
|
||||
|
|
@ -414,8 +420,8 @@ fn frame_holds_projection_verbatim() {
|
|||
assert!(!prompt.contains(foreign), "{foreign} reached the continuer");
|
||||
}
|
||||
let projected = model.projected.borrow();
|
||||
assert!(projected[0].contains("private Projector") && projected[0].contains(STREAM_GUIDANCE), "the Projector prompt is Ghostlight's builder over her seed");
|
||||
assert!(projected[0].contains("Libby holds the sounds"));
|
||||
assert!(projected[0].1.contains("private Projector") && projected[0].1.contains(STREAM_GUIDANCE), "the Projector prompt is Ghostlight's builder over her seed");
|
||||
assert!(projected[0].1.contains("Libby holds the sounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -465,7 +471,202 @@ fn stream_admits_only_thoughts() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The model port against a fake HTTP server.
|
||||
// The Projector against a real kit server.
|
||||
|
||||
use model_connector::{CallerAdmission, ProviderBackend, ProviderCall, ServeLimits, StopReason, TransportEvent, TransportResult, Usage, serve as serve_kit};
|
||||
|
||||
const KIT_KEY: &str = "stream-projector-test-key";
|
||||
const KIT_MODEL: &str = "claude-haiku-5-5";
|
||||
|
||||
/// Answers every call as scripted and records the caller and the request the provider would have been shown.
|
||||
struct Scripted {
|
||||
outcome: TransportOutcome,
|
||||
words: Vec<&'static str>,
|
||||
seen: Mutex<Vec<(String, ProviderRequest)>>,
|
||||
}
|
||||
|
||||
impl Scripted {
|
||||
fn ending(stop_reason: StopReason, words: &[&'static str]) -> Arc<Self> {
|
||||
let usage = Usage { input_tokens: 1, cache_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1, reasoning_tokens: None };
|
||||
Arc::new(Self { outcome: TransportOutcome::Completed { provider_response_id: Some("msg-1".into()), stop_reason, usage }, words: words.to_vec(), seen: Mutex::default() })
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderBackend for Scripted {
|
||||
fn ready(&self) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider(&self) -> &str {
|
||||
"anthropic-messages"
|
||||
}
|
||||
|
||||
fn execute(&self, call: &ProviderCall) -> TransportResult {
|
||||
self.seen.lock().unwrap().push((call.caller_runtime_id().to_owned(), call.request().clone()));
|
||||
let events = self.words.iter().enumerate().map(|(at, text)| TransportEvent { sequence: at as u64, payload: TransportEventPayload::TextDelta { text: (*text).into() } }).collect();
|
||||
call.transported(events, self.outcome.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// A running kit whose caller row for `instance` allows `allowed`, and a Projector opened on it.
|
||||
fn kit(backend: Arc<Scripted>, allowed: &str) -> (KitProjector, tempfile::TempDir) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = root.path().join("replay.cc");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let endpoint = listener.local_addr().unwrap();
|
||||
let admission = CallerAdmission::new(INSTANCE, KIT_KEY, 1, [allowed.to_owned()], 2, 64 * 1024, 4096).unwrap();
|
||||
let limits = ServeLimits { max_frame_bytes: 512 * 1024, max_connections: 8, socket_timeout_ms: 5000, max_expiry_skew_ms: 300_000 };
|
||||
std::thread::spawn(move || serve_kit(listener, limits, vec![admission], &store, backend));
|
||||
(KitProjector::open(endpoint, KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(5)).unwrap(), root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projector_is_one_kit_turn() {
|
||||
let backend = Scripted::ending(StopReason::EndTurn, &["I turn ", "it over."]);
|
||||
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
|
||||
let id = "mind-stream:eureka:t100";
|
||||
assert_eq!(projector.project(id, "The projector prompt.").unwrap(), "I turn it over.");
|
||||
let seen = backend.seen.lock().unwrap();
|
||||
let (caller, request) = &seen[0];
|
||||
assert_eq!(caller, INSTANCE, "her instance is the caller");
|
||||
assert_eq!((request.request_id.as_str(), request.conversation_id.as_str(), request.model.as_str()), (id, id, KIT_MODEL));
|
||||
assert_eq!(request.input, [InputItem::UserText { text: "The projector prompt.".into() }]);
|
||||
assert_eq!(request.instructions, PROJECTOR_INSTRUCTIONS);
|
||||
assert_eq!(request.reasoning_effort, Some(ReasoningEffort::Low));
|
||||
assert!(request.tools.is_empty() && request.output_schema_json.is_none());
|
||||
assert_eq!(request.max_output_tokens, PROJECTOR_MAX_OUTPUT_TOKENS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_projection_is_its_own_conversation() {
|
||||
let backend = Scripted::ending(StopReason::EndTurn, &["One."]);
|
||||
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
|
||||
projector.project("mind-stream:eureka:t100", "first").unwrap();
|
||||
projector.project("mind-stream:eureka:t200", "second").unwrap();
|
||||
let seen = backend.seen.lock().unwrap();
|
||||
assert_ne!(seen[0].1.request_id, seen[1].1.request_id);
|
||||
assert_ne!(seen[0].1.conversation_id, seen[1].1.conversation_id);
|
||||
assert_eq!(seen[1].1.input, [InputItem::UserText { text: "second".into() }], "no transcript is carried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kit_refusal_is_a_tick_fault() {
|
||||
let backend = Scripted::ending(StopReason::EndTurn, &["Never said."]);
|
||||
let (projector, _root) = kit(Arc::clone(&backend), "some-other-model");
|
||||
let mind = mind_with_thought(1000);
|
||||
let continuer = Fake::saying("unused", &["unused"]);
|
||||
let persona = persona();
|
||||
let wrap = Wrap::over(&mind, &[]);
|
||||
let organs = Organs { mind: &wrap, persona: &persona, projector: &projector, continuer: &continuer, base_model: "base:test" };
|
||||
let fault = format!("{:#}", tick(&organs, &|| released(), Mode::Live, t(1000 + STREAM_IDLE_GAP_S)).unwrap_err());
|
||||
assert!(fault.contains("ModelNotAllowed"), "{fault}");
|
||||
assert!(backend.seen.lock().unwrap().is_empty(), "the provider was never reached");
|
||||
assert_eq!(continuer.calls(), 0, "the continuer is not called");
|
||||
assert!(wrap.admitted.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thought_names_the_model_the_kit_ran() {
|
||||
let backend = Scripted::ending(StopReason::EndTurn, &["I turn it over."]);
|
||||
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
|
||||
let mind = mind_with_thought(1000);
|
||||
let continuer = Fake::saying("unused", &["A drift"]);
|
||||
let persona = persona();
|
||||
let wrap = Wrap::over(&mind, &[0.1]);
|
||||
let organs = Organs { mind: &wrap, persona: &persona, projector: &projector, continuer: &continuer, base_model: "base:test" };
|
||||
let Outcome::Thought { thought, .. } = tick(&organs, &|| released(), Mode::Live, t(1000 + STREAM_IDLE_GAP_S)).unwrap() else { panic!("a thought") };
|
||||
let ran = backend.seen.lock().unwrap()[0].1.model.clone();
|
||||
assert_eq!(ran, KIT_MODEL);
|
||||
assert_eq!(thought.projector_model.0, ran, "the recorded model is the one the kit was asked for");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_instructions_restate_no_membrane_text() {
|
||||
let membrane = build_projector_prompt(&ProjectorPrompt { identity: "", typed_context: "", visible_stimulus: "", domain_guidance: STREAM_GUIDANCE, word_budget: PROJECTOR_WORDS }).to_lowercase();
|
||||
let words: Vec<&str> = PROJECTOR_INSTRUCTIONS.split_whitespace().collect();
|
||||
assert!(!PROJECTOR_INSTRUCTIONS.contains('\n') && words.len() <= 20, "one short line");
|
||||
for run in words.windows(4) {
|
||||
let run = run.join(" ").to_lowercase();
|
||||
assert!(!membrane.contains(&run), "Ghostlight's membrane text reached the instructions: {run}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_connector_key_is_read_trimmed_and_its_absence_leaks_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("connector-key"), "canary-key-7f3a91\n").unwrap();
|
||||
assert_eq!(connector_key(dir.path()).unwrap(), "canary-key-7f3a91");
|
||||
for (written, read) in [("canary-key-7f3a91\r\n", "canary-key-7f3a91"), ("canary-key-7f3a91\n\n\n", "canary-key-7f3a91"), (" \t\ncanary-key-7f3a91 \t\r\n\n", "canary-key-7f3a91"), ("canary-key-7f3a91", "canary-key-7f3a91")] {
|
||||
std::fs::write(dir.path().join("connector-key"), written).unwrap();
|
||||
assert_eq!(connector_key(dir.path()).unwrap(), read, "{written:?}");
|
||||
}
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
let fault = format!("{:#}", connector_key(empty.path()).unwrap_err());
|
||||
assert!(fault.contains("connector-key") && !fault.contains(empty.path().to_str().unwrap()), "{fault}");
|
||||
let bad = tempfile::tempdir().unwrap();
|
||||
std::fs::write(bad.path().join("connector-key"), b"canary-key-7f3a91\xff\xfe").unwrap();
|
||||
let fault = format!("{:#}", connector_key(bad.path()).unwrap_err());
|
||||
assert!(!fault.contains("canary"), "no byte of the key reaches an error: {fault}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_open_error_carries_any_part_of_the_key() {
|
||||
let key = "zq7Kx-9wVtm3Pd";
|
||||
// Every way open() can fail with the key in hand: a non-loopback endpoint and a configuration the client refuses.
|
||||
for endpoint in ["10.77.0.1:4103", "127.0.0.1:0", "0.0.0.0:4103"] {
|
||||
let fault = format!("{:#}", KitProjector::open(endpoint.parse().unwrap(), key.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).err().unwrap());
|
||||
for window in key.as_bytes().windows(4) {
|
||||
assert!(!fault.contains(std::str::from_utf8(window).unwrap()), "{endpoint}: part of the key reached the error: {fault}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_projection_cut_short_is_a_fault() {
|
||||
for stop in [StopReason::MaxOutputTokens, StopReason::Refusal, StopReason::ToolUse, StopReason::ContextWindow] {
|
||||
let (projector, _root) = kit(Scripted::ending(stop, &["Half a thou"]), KIT_MODEL);
|
||||
let fault = format!("{:#}", projector.project("mind-stream:eureka:t1", "p").unwrap_err());
|
||||
assert!(fault.contains("ended early") && !fault.contains("Half a thou"), "{stop:?}: {fault}");
|
||||
}
|
||||
let failed = Arc::new(Scripted { outcome: TransportOutcome::Failed { kind: model_connector::FailureKind::RateLimited, message: "slow down".into(), usage: None }, words: vec![], seen: Mutex::default() });
|
||||
let (projector, _root) = kit(failed, KIT_MODEL);
|
||||
assert!(format!("{:#}", projector.project("mind-stream:eureka:t1", "p").unwrap_err()).contains("RateLimited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_loopback_connector_refused() {
|
||||
for refused in ["10.77.0.1:4103", "192.0.2.1:4103", "[2001:db8::1]:4103"] {
|
||||
assert!(KitProjector::open(refused.parse().unwrap(), KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).is_err(), "{refused} must be refused");
|
||||
}
|
||||
for opened in ["127.0.0.1:4103", "[::1]:4103"] {
|
||||
assert!(KitProjector::open(opened.parse().unwrap(), KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).is_ok(), "{opened} is loopback");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_ids_name_the_tick_and_a_dry_run_never_takes_the_live_one() {
|
||||
let mind = mind_with_thought(1000);
|
||||
let persona = persona();
|
||||
let at = t(1000 + STREAM_IDLE_GAP_S);
|
||||
let run = |mode, now: DateTime<Utc>| {
|
||||
let model = Fake::saying("I turn it over.", &["A drift"]);
|
||||
let wrap = Wrap::over(&mind, &[0.1]);
|
||||
tick(&organs(&wrap, &persona, &model), &|| released(), mode, now).unwrap();
|
||||
model.projected.into_inner().remove(0).0
|
||||
};
|
||||
let live = run(Mode::Live, at);
|
||||
assert_eq!(live, format!("mind-stream:{INSTANCE}:t{}", at.timestamp()));
|
||||
let dry = run(Mode::DryRun, at);
|
||||
let later_dry = run(Mode::DryRun, at + Span::milliseconds(5));
|
||||
assert_ne!(dry, live, "the kit's replay store would answer the live id byte-identically");
|
||||
assert!(dry.starts_with(&format!("{live}:dry")), "{dry}");
|
||||
assert_ne!(dry, later_dry, "two dry runs of one label do not collide");
|
||||
let next = run(Mode::Live, at + Span::seconds(STREAM_IDLE_GAP_S));
|
||||
assert_ne!(next, live, "each tick is its own conversation");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The continuer against a fake HTTP server.
|
||||
|
||||
type Seen = Arc<Mutex<Vec<(String, Value)>>>;
|
||||
|
||||
|
|
@ -503,7 +704,7 @@ fn serve(answers: Vec<String>) -> (String, Seen) {
|
|||
#[test]
|
||||
fn raw_generate_is_raw() {
|
||||
let (endpoint, seen) = serve(vec![json!({ "response": "one" }).to_string(), json!({ "response": "two" }).to_string()]);
|
||||
let model = OllamaStream::open(&endpoint, "projector:x", "base:y").unwrap();
|
||||
let model = OllamaContinuer::open(&endpoint, "base:y").unwrap();
|
||||
let out = model.continue_raw("The frame\n\n", &Sampling::STREAM, 2).unwrap();
|
||||
assert_eq!(out, ["one", "two"]);
|
||||
let seen = seen.lock().unwrap();
|
||||
|
|
@ -518,16 +719,6 @@ fn raw_generate_is_raw() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_projector_is_asked_in_chat() {
|
||||
let (endpoint, seen) = serve(vec![json!({ "message": { "role": "assistant", "content": "I hold it." } }).to_string()]);
|
||||
let model = OllamaStream::open(&endpoint, "projector:x", "base:y").unwrap();
|
||||
assert_eq!(model.project("Be the Projector.").unwrap(), "I hold it.");
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "/api/chat");
|
||||
assert_eq!(seen[0].1, json!({ "model": "projector:x", "stream": false, "messages": [{ "role": "user", "content": "Be the Projector." }] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_host_that_answers_badly_is_a_fault() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
|
@ -538,8 +729,8 @@ fn a_model_host_that_answers_badly_is_a_fault() {
|
|||
let _ = socket.read(&mut buffer);
|
||||
let _ = socket.write_all(b"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 4\r\nconnection: close\r\n\r\nboom");
|
||||
});
|
||||
let model = OllamaStream::open(&endpoint, "p", "b").unwrap();
|
||||
let fault = model.project("x").unwrap_err().to_string();
|
||||
let model = OllamaContinuer::open(&endpoint, "b").unwrap();
|
||||
let fault = model.continue_raw("x", &Sampling::STREAM, 1).unwrap_err().to_string();
|
||||
assert!(fault.contains("500"), "{fault}");
|
||||
}
|
||||
|
||||
|
|
@ -560,13 +751,13 @@ fn non_loopback_endpoint_refused() {
|
|||
"http://",
|
||||
"",
|
||||
] {
|
||||
assert!(OllamaStream::open(refused, "p", "b").is_err(), "{refused} must be refused");
|
||||
assert!(OllamaContinuer::open(refused, "b").is_err(), "{refused} must be refused");
|
||||
}
|
||||
for refused in ["http://10.77.0.1:11434", "http://example.com:11434", "http://0.0.0.0:11434", "https://127.0.0.1:11434", "127.0.0.1:11434", "http://127.0.0.1:11434/api", "http://[2001:db8::1]:11434"] {
|
||||
assert!(OllamaStream::open(refused, "p", "b").is_err(), "{refused} must be refused");
|
||||
assert!(OllamaContinuer::open(refused, "b").is_err(), "{refused} must be refused");
|
||||
}
|
||||
for opened in ["http://127.0.0.1:11434", "http://localhost:11434", "http://[::1]:11434", "http://127.0.0.2:1/", "http://LOCALHOST:11434/"] {
|
||||
assert!(OllamaStream::open(opened, "p", "b").is_ok(), "{opened} is loopback");
|
||||
assert!(OllamaContinuer::open(opened, "b").is_ok(), "{opened} is loopback");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -706,7 +897,7 @@ fn old_repo_activity_does_not_make_a_tick_due() {
|
|||
let model = Fake::saying("I turn it over.", &["A new drift"]);
|
||||
let persona = CountedPersona(persona(), std::cell::Cell::new(0));
|
||||
let wrap = Wrap::over(&mind, &[0.1]).with_forge(vec![push(1, 500)]);
|
||||
let organs = Organs { mind: &wrap, persona: &persona, model: &model, projector_model: "projector:test", base_model: "base:test" };
|
||||
let organs = Organs { mind: &wrap, persona: &persona, projector: &model, continuer: &model, base_model: "base:test" };
|
||||
assert_eq!(tick(&organs, &|| released(), Mode::Live, t(1901)).unwrap(), Outcome::NotDue);
|
||||
assert_eq!((model.calls(), persona.1.get(), thoughts(&mind).len()), (0, 0, 1), "no model call, no seed, no thought");
|
||||
}
|
||||
|
|
@ -777,7 +968,7 @@ fn every_thought_records_the_fixed_parts_of_its_prompts() {
|
|||
assert!(version.len() <= LINE_MAX_BYTES, "{}", version.len());
|
||||
assert!(include_str!("../../Cargo.toml").contains(GHOSTLIGHT_REV), "the recorded revision is the manifest's pin");
|
||||
let rebuilt = build_projector_prompt(&ProjectorPrompt { identity: &thought.identity.0, typed_context: &thought.context.0, visible_stimulus: &thought.stimulus.0, domain_guidance: STREAM_GUIDANCE, word_budget: PROJECTOR_WORDS });
|
||||
assert_eq!(model.projected.borrow()[0], rebuilt, "the trace rebuilds the Projector's exact prompt");
|
||||
assert_eq!(model.projected.borrow()[0].1, rebuilt, "the trace rebuilds the Projector's exact prompt");
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -660,6 +660,8 @@ impl Child for Scripted {
|
|||
pub(crate) struct Fake {
|
||||
pub(crate) child: RefCell<Option<Scripted>>,
|
||||
pub(crate) spawns: RefCell<Vec<Vec<String>>>,
|
||||
/// The `HUGINN_SESSION` each spawn was given, in order with `spawns`.
|
||||
pub(crate) sessions: RefCell<Vec<Option<String>>>,
|
||||
pub(crate) refuse: bool,
|
||||
/// The conversation the spawner reports as kept; a test sets it.
|
||||
pub(crate) kept: RefCell<Option<Kept>>,
|
||||
|
|
@ -670,7 +672,7 @@ pub(crate) struct Fake {
|
|||
impl Fake {
|
||||
pub(crate) fn with(child: Option<Scripted>, refuse: bool) -> Self {
|
||||
let log = child.as_ref().map(|child| child.log.clone());
|
||||
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
|
||||
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), sessions: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -686,8 +688,9 @@ impl Spawner for Fake {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
|
||||
fn spawn(&self, args: &[String], _token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
|
||||
self.spawns.borrow_mut().push(args.to_vec());
|
||||
self.sessions.borrow_mut().push(session.map(str::to_string));
|
||||
if self.refuse {
|
||||
return Err(anyhow!("refused"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,32 @@ use std::thread::JoinHandle;
|
|||
use std::time::Duration;
|
||||
|
||||
use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions};
|
||||
use mind_body::chat_client::{self, ACK, Chat, ChatAck, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST};
|
||||
use mind_body::chat_client::{self, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V1, PENDING_V2, READ, SEND_RECEIPT, SEND_REQUEST};
|
||||
use mind_body::chat_rudp::{RudpChat, connection_id};
|
||||
|
||||
const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
|
||||
const PENDING_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
|
||||
const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
|
||||
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
|
||||
const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack");
|
||||
const PENDING_TAILED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack");
|
||||
const PENDING_TAIL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack");
|
||||
// The four-field `chat_pending.v3` of a mouth with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged.
|
||||
const PENDING_GAPS_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack");
|
||||
const PENDING_V2_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
|
||||
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack");
|
||||
const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
|
||||
const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
|
||||
const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
|
||||
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
|
||||
const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack");
|
||||
const REFUSAL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack");
|
||||
const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack");
|
||||
|
||||
const KEY: &str = "connection-key-for-test";
|
||||
const TAG: &str = "cultnet.transport.rudp.v0";
|
||||
|
||||
/// What a fixture mouth answers an open with: the document kind, a record key (none: the request's own), and its bytes.
|
||||
type OpenReply = (&'static str, Option<&'static str>, &'static [u8]);
|
||||
|
||||
type Seen = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
struct Mouth {
|
||||
|
|
@ -66,17 +79,22 @@ fn mouth(key: &str) -> Mouth {
|
|||
mouth_at(key, "127.0.0.1:0")
|
||||
}
|
||||
|
||||
/// A mouth that has no image field yet: it answers a pending request in the v1 kind.
|
||||
/// A mouth that has no image field yet: it answers a pending request in the v1 kind, and does not serve the v2 request.
|
||||
fn legacy_mouth(key: &str) -> Mouth {
|
||||
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES)
|
||||
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
|
||||
}
|
||||
|
||||
/// A mouth that has the image field and not the addressing facts: it answers the v1 request in the v2 kind, and does not serve the v2 request.
|
||||
fn imaged_mouth(key: &str) -> Mouth {
|
||||
serve(key, "127.0.0.1:0", PENDING_V2, PENDING_V2_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
|
||||
}
|
||||
|
||||
/// The same mouth on a chosen address: a restart binds the address the old one held.
|
||||
fn mouth_at(key: &str, bind: &str) -> Mouth {
|
||||
serve(key, bind, PENDING, PENDING_BYTES)
|
||||
serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
|
||||
}
|
||||
|
||||
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8]) -> Mouth {
|
||||
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8], open: OpenReply) -> Mouth {
|
||||
let socket = UdpSocket::bind(bind).unwrap();
|
||||
socket.set_read_timeout(Some(Duration::from_millis(20))).unwrap();
|
||||
let address = socket.local_addr().unwrap();
|
||||
|
|
@ -94,8 +112,12 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta
|
|||
log.lock().unwrap().push((document.schema_id.clone(), document.payload.clone()));
|
||||
let for_eureka = |persona: &str| persona == "eureka";
|
||||
let reply = match document.schema_id.as_str() {
|
||||
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
|
||||
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => {
|
||||
Some(if pending_kind == PENDING { answer(pending_kind, "eureka", pending_bytes) } else { answer(REFUSAL, "eureka", UNKNOWN_KIND_BYTES) })
|
||||
}
|
||||
PENDING_REQUEST_V1 if pending_kind != PENDING && for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
|
||||
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
|
||||
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
|
|
@ -123,7 +145,7 @@ fn pending_send_and_ack_cross_a_real_rudp_session_in_the_mouths_bytes() {
|
|||
let mouth = mouth(KEY);
|
||||
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(5));
|
||||
|
||||
let deliveries = chat.pending(3).unwrap();
|
||||
let deliveries = chat.pending(3).unwrap().deliveries;
|
||||
assert_eq!(deliveries, chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap().deliveries);
|
||||
assert_eq!(deliveries[0].seq, 7);
|
||||
|
||||
|
|
@ -185,8 +207,8 @@ fn a_client_recovers_from_a_restarted_mouth_on_its_next_call() {
|
|||
drop(first);
|
||||
let second = mouth_at(KEY, &address.to_string());
|
||||
assert!(chat.pending(0).is_err(), "the restarted mouth ignores a link it never saw: the call times out");
|
||||
assert_eq!(chat.pending(0).unwrap(), chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap().deliveries, "the next call opens a fresh link");
|
||||
assert_eq!(chat.pending(0).unwrap().len(), 1, "and keeps it");
|
||||
assert_eq!(chat.pending(0).unwrap(), chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap(), "the next call opens a fresh link");
|
||||
assert_eq!(chat.pending(0).unwrap().deliveries.len(), 1, "and keeps it");
|
||||
let reads = second.seen.lock().unwrap().iter().filter(|(kind, _)| kind == PENDING_REQUEST).count();
|
||||
assert_eq!(reads, 2, "the restarted mouth read the two requests that reached it on a fresh link");
|
||||
}
|
||||
|
|
@ -203,13 +225,126 @@ fn a_send_that_timed_out_is_not_repeated_by_the_client() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_v1_mouths_pending_answer_still_reads_with_no_image_and_a_v2_one_carries_it() {
|
||||
let legacy = legacy_mouth(KEY);
|
||||
let old = client(&legacy, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
|
||||
assert_eq!(old.len(), 1);
|
||||
assert!(old[0].image.is_none());
|
||||
let current = mouth(KEY);
|
||||
let new = client(¤t, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
|
||||
assert_eq!(new[0].image.as_ref().map(|image| (image.mime.as_str(), image.size)), Some(("image/png", 2048)));
|
||||
assert_eq!(new[0].text, old[0].text, "the same message, with its attachment");
|
||||
fn a_mouth_without_the_v2_request_is_asked_the_v1_way_and_its_deliveries_carry_no_addressing_facts() {
|
||||
for (mouth, text) in [(legacy_mouth(KEY), "v1 answer"), (imaged_mouth(KEY), "v2 answer")] {
|
||||
let old = client(&mouth, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap();
|
||||
assert_eq!(old.tail, None, "{text}: a v1 request is answered with no tail");
|
||||
let old = old.deliveries;
|
||||
assert_eq!(old.len(), 1, "{text}");
|
||||
assert_eq!((old[0].basis, old[0].form), (None, Form::Message), "{text}");
|
||||
let seen: Vec<String> = mouth.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
|
||||
assert_eq!(seen, [PENDING_REQUEST, PENDING_REQUEST_V1], "{text}: the v2 request first, refused as an unknown kind, then the v1 request");
|
||||
assert_eq!(mouth.seen.lock().unwrap()[1].1, PENDING_REQUEST_V1_BYTES, "{text}");
|
||||
}
|
||||
assert!(client(&imaged_mouth(KEY), KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap().deliveries[0].image.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_current_mouth_answers_the_v2_request_with_basis_and_form() {
|
||||
let current = mouth(KEY);
|
||||
let new = client(¤t, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap().deliveries;
|
||||
assert_eq!((new[0].basis, new[0].form), (Some(Basis::Tag), Form::Message));
|
||||
let kinds: Vec<String> = current.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
|
||||
assert_eq!(kinds, [PENDING_REQUEST], "no v1 request follows an answered v2 request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_another_open_is_refused() {
|
||||
let mouth = mouth(KEY);
|
||||
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(5));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let receipt = chat.open_direct(&request).unwrap();
|
||||
assert_eq!(receipt, chat_client::decode::<ChatOpenDirectReceipt>(OPENED_BYTES).unwrap());
|
||||
assert_eq!(receipt.outcome, DirectOutcome::Opened);
|
||||
|
||||
let another = ChatOpenDirectRequest { request_id: "req-other".into(), ..request };
|
||||
assert!(chat.open_direct(&another).is_err(), "the answer names req-1, not req-other");
|
||||
let seen = mouth.seen.lock().unwrap().clone();
|
||||
assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec()));
|
||||
}
|
||||
|
||||
/// A mouth that answers every open with `reply`.
|
||||
fn opening_with(reply: OpenReply) -> Mouth {
|
||||
serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mouth_that_does_not_serve_the_open_kind_refuses_it_and_the_open_is_told_not_sent_at_once() {
|
||||
let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES));
|
||||
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let error = chat.open_direct(&request).unwrap_err();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
|
||||
assert_eq!(error.downcast_ref::<MouthRefused>(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() {
|
||||
let wrong_persona = opening_with((REFUSAL, Some("someone-else"), UNKNOWN_KIND_BYTES));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let error = client(&wrong_persona, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "a refusal keyed by another persona is not heard: {error}");
|
||||
let wrong_kind = opening_with((SEND_RECEIPT, Some("eureka"), SEND_RECEIPT_BYTES));
|
||||
let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "another kind is not heard: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_of_another_kind_ends_the_wait_but_is_not_told_as_not_sent() {
|
||||
// The fixture refusal is persona "ghost", UnknownPersona: the mouth refuses this Persona's open for a reason other than the kind.
|
||||
let mouth = opening_with((REFUSAL, Some("ghost"), REFUSAL_BYTES));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let error = client(&mouth, KEY, "ghost", Duration::from_secs(20)).open_direct(&request).unwrap_err();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "only UnknownKind is a known not-sent: {error}");
|
||||
assert!(error.to_string().contains("UnknownPersona"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_refusal_keyed_by_this_persona_but_naming_another_is_not_heard_as_not_sent() {
|
||||
let other = chat_client::encode(&ChatRefusal { persona: "someone-else".into(), refusal: Refusal::UnknownKind }).unwrap();
|
||||
let mouth = opening_with((REFUSAL, Some("eureka"), Box::leak(other.into_boxed_slice())));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let error = client(&mouth, KEY, "eureka", Duration::from_secs(20)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "the payload names another Persona: {error}");
|
||||
}
|
||||
|
||||
/// The wire, not only the decoder: the three-field `chat_pending.v3` of Bifrost e8722d0 crosses a real RUDP session as `chat_pending.v3`,
|
||||
/// and the tail arrives; the two-field answer of the mouth before it (the fixture the other tests serve) arrives with none.
|
||||
#[test]
|
||||
fn the_three_field_v3_crosses_real_rudp_with_its_tail_and_the_two_field_one_with_none() {
|
||||
let tailed = serve(KEY, "127.0.0.1:0", PENDING, PENDING_TAIL_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES));
|
||||
let got = client(&tailed, KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
|
||||
assert_eq!(got, chat_client::decode::<ChatPending>(PENDING_TAIL_BYTES).unwrap());
|
||||
assert_eq!(got.tail.as_ref().map(Vec::len), Some(2));
|
||||
let with_delivery = serve(KEY, "127.0.0.1:0", PENDING, PENDING_TAILED_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES));
|
||||
let got = client(&with_delivery, KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
|
||||
assert_eq!((got.deliveries.len(), got.tail), (1, Some(vec![])), "a delivery and an empty tail: the mouth had nothing to add");
|
||||
let old = client(&mouth(KEY), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
|
||||
assert_eq!((old.deliveries.len(), old.tail), (1, None), "the older mouth's two-field answer carries no tail");
|
||||
}
|
||||
|
||||
/// The three-field answer with a fourth element appended, as a four-field mouth would send it.
|
||||
fn four_field(three: &[u8], gaps: &[&str]) -> &'static [u8] {
|
||||
let mut value: serde_json::Value = rmp_serde::from_slice(three).unwrap();
|
||||
value.as_array_mut().unwrap().push(serde_json::json!(gaps));
|
||||
Box::leak(rmp_serde::to_vec(&value).unwrap().into_boxed_slice())
|
||||
}
|
||||
|
||||
/// Deploy safety for `tail_gaps` (Bifrost b2e0bb2): the three-field answer, the four-field one with gaps and the four-field one with
|
||||
/// none all cross real RUDP and decode. Only a named room is a gap; a quiet room is never in the list.
|
||||
#[test]
|
||||
fn the_four_field_v3_crosses_real_rudp_with_its_gaps_and_the_three_field_one_with_none() {
|
||||
let fetch = |bytes: &'static [u8]| client(&serve(KEY, "127.0.0.1:0", PENDING, bytes, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
|
||||
let three = fetch(PENDING_TAIL_BYTES);
|
||||
assert_eq!((three.tail.as_ref().map(Vec::len), three.tail_gaps), (Some(2), Vec::<String>::new()), "a three-field answer names no gap");
|
||||
let gapped = fetch(PENDING_GAPS_BYTES);
|
||||
assert_eq!((gapped.deliveries.len(), gapped.tail.as_ref().map(Vec::len)), (0, Some(1)));
|
||||
assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()], "the mouth's own bytes name two rooms");
|
||||
let empty = fetch(four_field(PENDING_TAIL_BYTES, &[]));
|
||||
assert_eq!((empty.tail.as_ref().map(Vec::len), empty.tail_gaps), (Some(2), Vec::<String>::new()), "an empty fourth element is every room read fully");
|
||||
let quiet = fetch(four_field(PENDING_TAIL_BYTES, &["#swarm:gamecult.org"]));
|
||||
assert!(!quiet.tail_gaps.contains(&"#operator:gamecult.org".to_string()), "a room the mouth did not name is not a gap, however few lines it has");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ fn brake_hold_stops_once_with_the_instance_and_the_store_already_held() {
|
|||
result.unwrap();
|
||||
assert_eq!(calls, [Some("eureka".to_string())]);
|
||||
assert!(seen_at_stop.contains("BrakeHeld"), "the write comes before the stop: {seen_at_stop}");
|
||||
assert!(out.contains("units: stopped mind-self@eureka.service mind-persona@eureka.service"), "{out}");
|
||||
assert!(out.lines().any(|line| line == "units: stopped mind-self@eureka.service mind-persona@eureka.service mind-stream@eureka.service"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -362,8 +362,8 @@ fn hold_through_the_binary(exit: i32) -> (std::process::Output, Vec<String>, tem
|
|||
fn the_binary_hold_runs_systemctl_stop_on_exactly_the_units_it_starts() {
|
||||
let (output, recorded, _root) = hold_through_the_binary(0);
|
||||
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert_eq!(recorded, ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"], "a stop that succeeds asks no state");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("units: stopped mind-self@eureka.service mind-persona@eureka.service"));
|
||||
assert_eq!(recorded, ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service"], "a stop that succeeds asks no state");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("units: stopped mind-self@eureka.service mind-persona@eureka.service mind-stream@eureka.service"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
@ -373,11 +373,11 @@ fn a_partial_stop_names_each_units_state_and_keeps_the_brake_held_with_exit_2()
|
|||
assert_eq!(output.status.code(), Some(2));
|
||||
assert_eq!(
|
||||
recorded,
|
||||
["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "is-active", "mind-self@eureka.service", "is-active", "mind-persona@eureka.service"]
|
||||
["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service", "is-active", "mind-self@eureka.service", "is-active", "mind-persona@eureka.service", "is-active", "mind-stream@eureka.service"]
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("exit status 5") && stderr.contains("the brake is held"), "{stderr}");
|
||||
assert!(stderr.contains("mind-self@eureka.service is inactive") && stderr.contains("mind-persona@eureka.service is active"), "{stderr}");
|
||||
assert!(stderr.contains("mind-self@eureka.service is inactive") && stderr.contains("mind-persona@eureka.service is active") && stderr.contains("mind-stream@eureka.service is active"), "{stderr}");
|
||||
assert!(!stderr.contains("were not stopped"), "{stderr}");
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("stopped"));
|
||||
assert!(shown(root.path()).contains("BrakeHeld"));
|
||||
|
|
@ -475,7 +475,7 @@ fn swarm_brake_hold_stops_once_with_the_swarm_scope_and_the_swarm_store_already_
|
|||
result.unwrap();
|
||||
assert_eq!(calls, [None], "one stop, for the swarm");
|
||||
assert!(seen_at_stop.contains("BrakeHeld"), "the write comes before the stop: {seen_at_stop}");
|
||||
assert!(out.contains("units: stopped every mind-self@ and mind-persona@ unit"), "{out}");
|
||||
assert!(out.lines().any(|line| line == "units: stopped every mind-self@*.service mind-persona@*.service mind-stream@*.service"), "the line names exactly what stop_args stopped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
”¥req-3¨DeclinedÀÀ
|
||||
|
|
@ -0,0 +1 @@
|
|||
”¥req-2¨Existing±!dm1:gamecult.org¦$line2
|
||||
|
|
@ -0,0 +1 @@
|
|||
”¥req-1¦Opened±!dm1:gamecult.org¦$line1
|
||||
|
|
@ -0,0 +1 @@
|
|||
”¥req-1¦eureka²@meta:gamecult.orgªhello Meta
|
||||
|
|
@ -0,0 +1 @@
|
|||
’¦eureka«UnknownKind
|
||||
BIN
crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
BIN
crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
’¦eureka
|
||||
BIN
crates/mind-body/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
BIN
crates/mind-body/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/mind-body/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack
vendored
Normal file
BIN
crates/mind-body/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
crates/mind-body/tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack
vendored
Normal file
BIN
crates/mind-body/tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack
vendored
Normal file
Binary file not shown.
|
|
@ -14,33 +14,59 @@ mind_body: mod tempo
|
|||
mind_body::address: impl Addressee
|
||||
mind_body::address: struct Addressee # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
|
||||
mind_body::address: struct Name # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , }
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , Default , PartialEq , Eq)] pub enum Form { # [default] Message , Notice , Edit , }
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Basis { Direct , Tag , Reply , Room , }
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum DirectOutcome { Opened , Existing , Brake , PersonaMismatch , UnknownPersona , EmptyText , ForeignUser , NotAPerson , NoSuchUser , InvitePending , Declined , DailyLimit , SendFailed , }
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , UnknownKind , }
|
||||
mind_body::chat_client: const ACK : & str
|
||||
mind_body::chat_client: const OPEN_DIRECT_RECEIPT : & str
|
||||
mind_body::chat_client: const OPEN_DIRECT_REQUEST : & str
|
||||
mind_body::chat_client: const PENDING : & str
|
||||
mind_body::chat_client: const PENDING_REQUEST : & str
|
||||
mind_body::chat_client: const PENDING_REQUEST_V1 : & str
|
||||
mind_body::chat_client: const PENDING_V1 : & str
|
||||
mind_body::chat_client: const PENDING_V2 : & str
|
||||
mind_body::chat_client: const READ : & str
|
||||
mind_body::chat_client: const REFUSAL : & str
|
||||
mind_body::chat_client: const SEND_RECEIPT : & str
|
||||
mind_body::chat_client: const SEND_REQUEST : & str
|
||||
mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T >
|
||||
mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > >
|
||||
mind_body::chat_client: impl Basis
|
||||
mind_body::chat_client: impl Basis: fn name (self) -> & 'static str
|
||||
mind_body::chat_client: impl DirectOutcome
|
||||
mind_body::chat_client: impl DirectOutcome: fn name (self) -> & 'static str
|
||||
mind_body::chat_client: impl Form
|
||||
mind_body::chat_client: impl Form: fn name (self) -> & 'static str
|
||||
mind_body::chat_client: impl Refusal
|
||||
mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str
|
||||
mind_body::chat_client: impl Serialize for Basis
|
||||
mind_body::chat_client: impl Serialize for DirectOutcome
|
||||
mind_body::chat_client: impl Serialize for Form
|
||||
mind_body::chat_client: impl Serialize for Refusal
|
||||
mind_body::chat_client: impl TryFrom < RawOpenReceipt > for ChatOpenDirectReceipt
|
||||
mind_body::chat_client: impl std :: error :: Error for MouthRefused
|
||||
mind_body::chat_client: impl std :: fmt :: Display for MouthRefused
|
||||
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Basis
|
||||
mind_body::chat_client: impl< 'de > Deserialize < 'de > for DirectOutcome
|
||||
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Form
|
||||
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal
|
||||
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; }
|
||||
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < ChatPending > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; }
|
||||
mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64]
|
||||
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >]
|
||||
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >, basis: Option < Basis >, form: Form]
|
||||
mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String]
|
||||
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >]
|
||||
mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >]
|
||||
mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String]
|
||||
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >, tail: Option < Vec < TailLine > >, tail_gaps: Vec < String >]
|
||||
mind_body::chat_client: struct ChatPendingRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, after_seq: u64]
|
||||
mind_body::chat_client: struct ChatRead # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, room: String, event_id: String, at_unix_ms: u64]
|
||||
mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, refusal: Refusal]
|
||||
mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < Refusal >, newer: Vec < String >]
|
||||
mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String, basis: Option < SendBasis >]
|
||||
mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64]
|
||||
mind_body::chat_client: struct MouthRefused # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[0: Refusal]
|
||||
mind_body::chat_client: struct SendBasis # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seen_event_id: Option < String >, since_unix_ms: u64]
|
||||
mind_body::chat_client: struct TailLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, event_id: String, at_unix_ms: u64, sender: String, form: Form, basis: Option < Basis >, body: String]
|
||||
mind_body::chat_image: impl Skip
|
||||
mind_body::chat_rudp: fn connection_id (key_file_text : & str) -> u32
|
||||
mind_body::chat_rudp: impl Chat for RudpChat
|
||||
|
|
@ -163,7 +189,7 @@ mind_body::lease_client: struct SocketLender pub-fields[]
|
|||
mind_body::lease_client: struct TokenFd pub-fields[]
|
||||
mind_body::lease_client::socket: impl Lease for Granted
|
||||
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize)] # [serde (rename_all = "lowercase")] pub enum SettleOutcome { Done , Declined , Wrong , }
|
||||
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RoomNotAllowed , Send , SelfRun , Memory , Ack , }
|
||||
mind_body::persona: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RuleRepo (String) , RoomNotAllowed , Send , SelfRun , Memory , Ack , }
|
||||
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
|
||||
mind_body::persona: const AGENT : & str
|
||||
mind_body::persona: const DOCTRINE_MAX_BYTES : usize
|
||||
|
|
@ -179,7 +205,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str
|
|||
mind_body::persona: const PERSONA_POLL_S : i64
|
||||
mind_body::persona: const SANDBOX_DIR : & str
|
||||
mind_body::persona: fn carry (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome >
|
||||
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String]) -> String
|
||||
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , may_open : bool) -> String
|
||||
mind_body::persona: impl Config
|
||||
mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) >
|
||||
mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) >
|
||||
|
|
@ -205,9 +231,11 @@ mind_body::persona: pub use model_connector :: PlanQuota ;
|
|||
mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, model: String, doctrine: Vec < PathBuf >, image_store: Option < PathBuf >]
|
||||
mind_body::persona: struct FileTrace pub-fields[]
|
||||
mind_body::persona: struct HuginnPersona pub-fields[]
|
||||
mind_body::persona: struct OpenDirect # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[with: String, text: String]
|
||||
mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, heartbeat: Option < & 'a dyn HeartbeatView >, lender: & 'a dyn Lender, spawner: & 'a dyn Spawner, run_cap_s: u64, sandbox: & 'a Path, scratch: & 'a Path]
|
||||
mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >]
|
||||
mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >]
|
||||
mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16]
|
||||
mind_body::persona: struct Rule # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[by: String, words: String, repo: String, campaign: String, title: String, ruling: String]
|
||||
mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String]
|
||||
mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String]
|
||||
mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >]
|
||||
|
|
@ -281,7 +309,7 @@ mind_body::self_run: impl std :: error :: Error for SeedLinkRefused
|
|||
mind_body::self_run: impl< 'a > SelfInbox < 'a >
|
||||
mind_body::self_run: mod inbox
|
||||
mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; }
|
||||
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
|
||||
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd , session : Option < & str >) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
|
||||
mind_body::self_run: struct ClaudeSpawner pub-fields[]
|
||||
mind_body::self_run: struct Kept # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[session: Uuid, transcript: PathBuf, warm_until: DateTime < Utc >, written_at: DateTime < Utc >, same_seed: bool]
|
||||
mind_body::self_run::decide: impl State
|
||||
|
|
@ -300,20 +328,26 @@ mind_body::stream: const AGENT : & str
|
|||
mind_body::stream: const STREAM_IDLE_GAP_S : i64
|
||||
mind_body::stream: const STREAM_MIN_GAP_S : i64
|
||||
mind_body::stream: const STREAM_POLL_S : i64
|
||||
mind_body::stream: fn connector_key (credentials : & std :: path :: Path) -> Result < String >
|
||||
mind_body::stream: fn tick (organs : & Organs , brake : & dyn Fn () -> Effective , mode : Mode , now : DateTime < Utc >) -> Result < Outcome >
|
||||
mind_body::stream: impl Continuer for OllamaContinuer
|
||||
mind_body::stream: impl HuginnPersonaSource
|
||||
mind_body::stream: impl HuginnPersonaSource: fn new (client : HuginnClient) -> Self
|
||||
mind_body::stream: impl OllamaStream
|
||||
mind_body::stream: impl OllamaStream: fn open (endpoint : & str , projector_model : & str , base_model : & str) -> Result < Self >
|
||||
mind_body::stream: impl KitProjector
|
||||
mind_body::stream: impl KitProjector: fn open (endpoint : SocketAddr , connection_key : String , caller : & str , model : & str , timeout : Duration) -> Result < Self >
|
||||
mind_body::stream: impl OllamaContinuer
|
||||
mind_body::stream: impl OllamaContinuer: fn open (endpoint : & str , base_model : & str) -> Result < Self >
|
||||
mind_body::stream: impl PersonaSource for HuginnPersonaSource
|
||||
mind_body::stream: impl Projector for KitProjector
|
||||
mind_body::stream: impl Sampling
|
||||
mind_body::stream: impl Seed
|
||||
mind_body::stream: impl StreamModel for OllamaStream
|
||||
mind_body::stream: pub trait Continuer { fn continue_raw (& self , prompt : & str , sampling : & Sampling , n : usize) -> Result < Vec < String > > ; }
|
||||
mind_body::stream: pub trait PersonaSource { fn persona (& self) -> Result < PersonaRead > ; }
|
||||
mind_body::stream: pub trait StreamModel { fn project (& self , prompt : & str) -> Result < String > ; fn continue_raw (& self , prompt : & str , sampling : & Sampling , n : usize) -> Result < Vec < String > > ; }
|
||||
mind_body::stream: pub trait Projector { fn model (& self) -> & str ; fn project (& self , request_id : & str , prompt : & str) -> Result < String > ; }
|
||||
mind_body::stream: struct HuginnPersonaSource pub-fields[]
|
||||
mind_body::stream: struct OllamaStream pub-fields[]
|
||||
mind_body::stream: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, persona: & 'a dyn PersonaSource, model: & 'a dyn StreamModel, projector_model: & 'a str, base_model: & 'a str]
|
||||
mind_body::stream: struct KitProjector pub-fields[]
|
||||
mind_body::stream: struct OllamaContinuer pub-fields[]
|
||||
mind_body::stream: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, persona: & 'a dyn PersonaSource, projector: & 'a dyn Projector, continuer: & 'a dyn Continuer, base_model: & 'a str]
|
||||
mind_body::stream: struct PersonaRead pub-fields[state: Value, receipt_id: String]
|
||||
mind_body::stream: struct Sampling # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[temperature_permille: u16, min_p_permille: u16, max_tokens: u16]
|
||||
mind_body::tempo: const DEFAULT_BEAT_S : u32
|
||||
|
|
|
|||
51
crates/mind-body/tests/stream_bin.rs
Normal file
51
crates/mind-body/tests/stream_bin.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//! The `mind-stream` binary's startup reads the connector key from systemd's credentials directory:
|
||||
//! an absent directory names the variable, and the key's bytes never reach stderr.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const CANARY: &str = "zq7Kx-9wVtm3Pd";
|
||||
|
||||
fn run(credentials: Option<&std::path::Path>, connector: &str) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_mind-stream"));
|
||||
command
|
||||
.args(["--instance", "eureka", "--once"])
|
||||
.env_clear()
|
||||
.env("HUGINN_ENDPOINT", "rudp://127.0.0.1:9")
|
||||
.env("MIND_STREAM_CONNECTOR_ENDPOINT", connector)
|
||||
.env("MIND_STREAM_PROJECTOR_MODEL", "claude-haiku-5-5")
|
||||
.env("MIND_STREAM_BASE_MODEL", "base:test")
|
||||
.env("MIND_STREAM_ENDPOINT", "http://127.0.0.1:1");
|
||||
if let Some(dir) = credentials {
|
||||
command.env("CREDENTIALS_DIRECTORY", dir);
|
||||
}
|
||||
command.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_credentials_directory_names_the_variable_and_a_missing_key_names_the_credential() {
|
||||
let output = run(None, "127.0.0.1:4103");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(stderr.contains("CREDENTIALS_DIRECTORY"), "{stderr}");
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&run(Some(empty.path()), "127.0.0.1:4103").stderr).to_string();
|
||||
assert!(stderr.contains("connector-key"), "{stderr}");
|
||||
}
|
||||
|
||||
/// Every four-byte run of the key: a partial key is as much a leak as the whole.
|
||||
fn leaks(shown: &str) -> Option<&str> {
|
||||
CANARY.as_bytes().windows(4).map(|window| std::str::from_utf8(window).unwrap()).find(|run| shown.contains(run))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_byte_run_of_the_key_is_printed_on_any_startup_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("connector-key"), format!("{CANARY}\n")).unwrap();
|
||||
// A non-loopback connector and a configuration the client refuses both end startup after the key was read and handed over.
|
||||
for (connector, ending) in [("10.77.0.1:4103", "not a loopback address"), ("127.0.0.1:0", "could not be configured")] {
|
||||
let output = run(Some(dir.path()), connector);
|
||||
let shown = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
assert!(shown.contains(ending), "{connector}: the key was read and startup went on: {shown}");
|
||||
assert_eq!(leaks(&shown), None, "{connector}: {shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use mind_body::stream::{OllamaStream, StreamModel};
|
||||
use mind_body::stream::{Continuer, OllamaContinuer, Sampling};
|
||||
|
||||
/// Counts the connections a loopback listener accepts, answering each with `reply` when given.
|
||||
fn listen(reply: Option<String>) -> (String, Arc<AtomicUsize>) {
|
||||
|
|
@ -67,7 +67,8 @@ fn the_stream_agent_ignores_the_proxy_environment() {
|
|||
let plain: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(2))).build().into();
|
||||
let _ = plain.get(&endpoint).call();
|
||||
} else {
|
||||
let _ = OllamaStream::open(&endpoint, "p", "b").unwrap().project("hello");
|
||||
let sampling = Sampling { temperature_permille: 1000, min_p_permille: 50, max_tokens: 16 };
|
||||
let _ = OllamaContinuer::open(&endpoint, "b").unwrap().continue_raw("hello", &sampling, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -864,7 +864,7 @@ Ruling `eureka-body:ruling:persona-session-landed` (landed; deployed 2026-11-01
|
|||
|
||||
Rulings: `tell-is-just-the-message`, `tell-delta-three-defaults`, `what-reaches-each-turn-ruled`. Owner: `persona.rs`. A session is one process and nothing compacts it, so what she was told stays in her context; a line carries only what is new.
|
||||
|
||||
- **First line of a process** (`PersonaInbox::start_line` -> `whole_line` -> `render_input`): `TURN_INSTRUCTION` (the Perfect Machine, stewardship, inversion of control, originator vision as evidence, her tools, work that changes a repo goes to a Self run), a refused batch's note if an earlier process left one, her latest runs, responsibilities, recall, plan windows and tempo, the rooms and people she may name, `MESSAGES_HEADER`, the messages, and the tail: an opener when there are no messages (`WAKE_ASK` on a day she has started no Self run of her own, else "No new messages."), then, when there are messages, `ANSWER_MESSAGES`, then `WORK_QUESTION` on every first line, with messages or without. Later lines end on `WORK_TAIL` (`tell_line`: plain, refused and stale retells alike). A wake is always a first line: a session starts with an empty batch and gets the whole machine, so a wake never arrives as a bare later line. A restart (cache TTL, cap rotation, ceiling flush, failed result) is a new process and tells the whole machine again.
|
||||
- **First line of a process** (`PersonaInbox::start_line` -> `whole_line` -> `render_input`): `TURN_INSTRUCTION` (the Perfect Machine, stewardship, inversion of control, originator vision as evidence, her tools, work that changes a repo goes to a Self run), a refused batch's note if an earlier process left one, her latest runs, responsibilities, recall, plan windows and tempo, the rooms and people she may name, the room tail (`render_tail`: the mouth's `tail` on `chat_pending.v3`, field 2, a defaulted `Option` so an older two-field mouth still decodes and is told the tail is unavailable; per speakable room the newest 20 and 60 in all, at most 24 KiB as rendered, framed as untrusted data with a truncation marker and a note naming rooms with no line; a delivery's event id drops its tail line; a DM session reads its own room alone; nothing is accumulated in the organ; ruling `wake-reads-room-tail`; deploy order: Huginn must deploy before Bifrost #51, because a Huginn without the `tail` field fails to decode the mouth's three-field reply), `MESSAGES_HEADER`, the messages, and the tail: an opener when there are no messages (`WAKE_ASK` on a day she has started no Self run of her own, else "No new messages."), then, when there are messages, `ANSWER_MESSAGES`, then `WORK_QUESTION` on every first line, with messages or without. Later lines end on `WORK_TAIL` (`tell_line`: plain, refused and stale retells alike). A wake is always a first line: a session starts with an empty batch and gets the whole machine, so a wake never arrives as a bare later line. A restart (cache TTL, cap rotation, ceiling flush, failed result) is a new process and tells the whole machine again.
|
||||
- **Later line** (`tell_line`): the organ's notice lines first, a refused batch's note before the messages, then `earlier`/`message` lines, then `WORK_TAIL`. No instruction, no machine; `tell_line` reads nothing from her mind.
|
||||
- **Notice lines**: `notice role=organ at=... text="Your Self run <label> ended: <outcome>"`. They are not messages and answer to no delivery. Source: one read of her Self run slot (`holders` for SelfRun) at most once per `SLOT_READ_EVERY_S`=15 s (the chat poll is 5 s; the slot read cost 1.4-2 s of daemon CPU on Eureka's 14.7k-document mind, so 5 s was 28-41% of a core); `slot_seen` holds the runs last seen holding it, whoever started them, and her own start joins it unread. A run seen to leave gets its closing read once. A failed read keeps `slot_seen` and delays the notice. Only a live session fills `ended`, so a notice never starts a session; with notices and no deliveries `poll` reports pending and `tell` writes a line of notices alone. A run she did not start that opens and ends between two reads is unseen (about 10 of 157 Self runs at 15 s); the next first line shows it.
|
||||
- **Flush** (`flush_text`): `TURN_REMINDER` (the stewardship sentence) plus the ask, never the first-line instruction. It records what it shows (`flush-shown`).
|
||||
|
|
|
|||
|
|
@ -80,13 +80,56 @@
|
|||
"type": "string"
|
||||
},
|
||||
"RulingAuthority": {
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Steward": {
|
||||
"properties": {
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repo": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"steward": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"words": {
|
||||
"$ref": "#/$defs/Para"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"steward",
|
||||
"repo",
|
||||
"words"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Steward"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Sha256Hex": {
|
||||
"description": "A SHA-256 digest: exactly 64 lowercase hex characters.",
|
||||
|
|
|
|||
229
schemas/cultnet/huginn.desk_request.v1.schema.json
Normal file
229
schemas/cultnet/huginn.desk_request.v1.schema.json
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "DeskRequest",
|
||||
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `subject`\nand `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability\ngate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn\nanswers every read with the whole record and Bifrost applies visibility; see `DeskResponse`\nfor the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Answer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authority": {
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/ForkAnswerRequest"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mind",
|
||||
"subject",
|
||||
"authority",
|
||||
"deciders",
|
||||
"request"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Answer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Forks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"pattern": "^c[1-9][0-9]*$"
|
||||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"limit": {
|
||||
"$ref": "#/$defs/ForkLimit"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"status": {
|
||||
"description": "Which forks a list asks for; absent is both. `recorded` is a closed card.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"recorded"
|
||||
]
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mind",
|
||||
"subject",
|
||||
"deciders",
|
||||
"status",
|
||||
"cursor",
|
||||
"limit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Forks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Fork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"fork_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mind",
|
||||
"subject",
|
||||
"deciders",
|
||||
"fork_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Fork"
|
||||
]
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"DeskAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Steward",
|
||||
"Member"
|
||||
]
|
||||
},
|
||||
"ForkAnswerRequest": {
|
||||
"description": "`gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the\nshape step of `Mind::answer_desk` judges it. The subject and the authority are the\ndoor's context and are no field here.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"description": "The person's words: a choice, a note, or both.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"choice": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"fork_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"options_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"retracted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "string"
|
||||
},
|
||||
"schemaVersion": {
|
||||
"$ref": "#/$defs/RequestSchema"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"answer",
|
||||
"retracted",
|
||||
"retracts",
|
||||
"answered_at",
|
||||
"request_id"
|
||||
]
|
||||
},
|
||||
"ForkLimit": {
|
||||
"type": "integer",
|
||||
"maximum": 50,
|
||||
"minimum": 0
|
||||
},
|
||||
"RequestSchema": {
|
||||
"description": "The request document's schema id; any other value fails to decode.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"gamecult.fork.answer_request.v0"
|
||||
]
|
||||
},
|
||||
"Short": {
|
||||
"description": "Text of at most 200 UTF-8 bytes.",
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"Slug": {
|
||||
"description": "A campaign slug or key segment: label parts joined by `.`.",
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
}
|
||||
1818
schemas/cultnet/huginn.desk_response.v1.schema.json
Normal file
1818
schemas/cultnet/huginn.desk_response.v1.schema.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2929,12 +2929,55 @@
|
|||
]
|
||||
},
|
||||
"RulingAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Steward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repo": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"steward": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"words": {
|
||||
"$ref": "#/$defs/Para"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"steward",
|
||||
"repo",
|
||||
"words"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Steward"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RunOperator": {
|
||||
|
|
|
|||
|
|
@ -5891,12 +5891,55 @@
|
|||
]
|
||||
},
|
||||
"RulingAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Standing",
|
||||
"Defaulted",
|
||||
"Mind"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Steward": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repo": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"steward": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"words": {
|
||||
"$ref": "#/$defs/Para"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"steward",
|
||||
"repo",
|
||||
"words"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Steward"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RunId": {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,26 @@
|
|||
"schemaVersion": "huginn.mind_response.v1",
|
||||
"title": "Huginn mind response",
|
||||
"path": "huginn.mind_response.v1.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "huginn.desk_request.v1",
|
||||
"kind": "wire_message",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "huginn.desk_request.v1",
|
||||
"title": "Huginn desk request",
|
||||
"path": "huginn.desk_request.v1.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "huginn.desk_response.v1",
|
||||
"kind": "wire_message",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "huginn.desk_response.v1",
|
||||
"title": "Huginn desk response",
|
||||
"path": "huginn.desk_response.v1.schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue