Merge remote-tracking branch 'origin/main' into eureka-body/mind-launch
This commit is contained in:
commit
5da8b06a6d
29 changed files with 4182 additions and 55 deletions
|
|
@ -20,7 +20,7 @@ Studio's job, not Huginn's.
|
|||
derived status included, over `cultcache-rs` and `eureka-pipeline`.
|
||||
`huginn-daemon` serves one mind over CultNet RUDP through `cultnet-rs`, and reaches the leaf and the store type through `huginn-mind`
|
||||
so the workspace's one CultLib revision (root `[workspace.dependencies]`) decides both. `eureka-state` is the client core: one call
|
||||
to a daemon over CultNet, holding no state.
|
||||
to a daemon over CultNet, holding no state; its `huginn` binary is the CLI agents reach the mind through, and it holds no state either.
|
||||
- Owned: an instance's memory documents, their admission and the read side
|
||||
that derives their status, in `huginn-mind`,
|
||||
over a redb CultCache store at `<state_root>/minds/<instance>/mind.redb`;
|
||||
|
|
|
|||
513
Cargo.lock
generated
513
Cargo.lock
generated
|
|
@ -48,6 +48,35 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
|
|
@ -57,6 +86,56 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
|
|
@ -98,6 +177,21 @@ version = "1.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
@ -119,6 +213,12 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
|
|
@ -149,6 +249,12 @@ version = "3.20.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
|
|
@ -201,6 +307,52 @@ dependencies = [
|
|||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
|
|
@ -357,6 +509,12 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -408,6 +566,15 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -443,6 +610,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cultnet-rs",
|
||||
"huginn-daemon",
|
||||
"huginn-mind",
|
||||
|
|
@ -455,6 +623,17 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
|
|
@ -473,6 +652,33 @@ version = "0.1.12"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
|
||||
dependencies = [
|
||||
"num",
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
|
|
@ -599,9 +805,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -631,7 +839,7 @@ version = "0.12.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -639,6 +847,17 @@ name = "hashbrown"
|
|||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
|
|
@ -693,10 +912,12 @@ dependencies = [
|
|||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"eureka-pipeline",
|
||||
"jsonschema",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
]
|
||||
|
|
@ -759,6 +980,12 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -776,6 +1003,59 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"bytecount",
|
||||
"data-encoding",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"itoa",
|
||||
"jsonschema-regex",
|
||||
"jsonschema-value",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"unicode-general-category",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
|
|
@ -824,6 +1104,12 @@ version = "2.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "micromap"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||
|
||||
[[package]]
|
||||
name = "mind-body"
|
||||
version = "0.1.0"
|
||||
|
|
@ -841,6 +1127,75 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -856,12 +1211,24 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.11.2"
|
||||
|
|
@ -870,7 +1237,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
|||
dependencies = [
|
||||
"instant",
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
"parking_lot_core 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core 0.9.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -882,11 +1259,24 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"instant",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.2.16",
|
||||
"smallvec",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
|
|
@ -1052,6 +1442,15 @@ dependencies = [
|
|||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reed-solomon-erasure"
|
||||
version = "6.0.0"
|
||||
|
|
@ -1060,7 +1459,7 @@ checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f"
|
|||
dependencies = [
|
||||
"libm",
|
||||
"lru",
|
||||
"parking_lot",
|
||||
"parking_lot 0.11.2",
|
||||
"smallvec",
|
||||
"spin",
|
||||
]
|
||||
|
|
@ -1085,6 +1484,52 @@ dependencies = [
|
|||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"fluent-uri",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.17.1",
|
||||
"itoa",
|
||||
"micromap",
|
||||
"parking_lot 0.12.5",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "2.2.0"
|
||||
|
|
@ -1296,6 +1741,17 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -1386,6 +1842,27 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -1550,6 +2027,12 @@ version = "1.20.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -1597,6 +2080,12 @@ version = "0.8.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
|
|
@ -1608,12 +2097,28 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ A Cargo workspace of three crates:
|
|||
the process, and no rule. The operation envelope both sides speak is
|
||||
`huginn-mind`'s.
|
||||
- `crates/eureka-state`: the client core, `HuginnClient::call`: one request to a
|
||||
daemon over CultNet RUDP, a deferred answer resolved, and no state.
|
||||
daemon over CultNet RUDP, a deferred answer resolved, and no state. Its
|
||||
`huginn` binary is the agents' CLI: whoami, view, query and named recipes,
|
||||
admit with read-back, schema.
|
||||
|
||||
`huginn-mind` is live: it opens one instance's store (an owned redb CultCache
|
||||
at `<state_root>/minds/<instance>/mind.redb`, locked for the mind's lifetime),
|
||||
|
|
|
|||
|
|
@ -11,10 +11,28 @@ publish.workspace = true
|
|||
# the daemon cannot spell them differently; the content fetch and its
|
||||
# verification are `cultnet-rs`'s.
|
||||
#
|
||||
# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one
|
||||
# consumer: four tools over stdio, one per wire operation, holding no state.
|
||||
# The crate is the client core (`lib.rs`) and its consumers: the `huginn` CLI
|
||||
# (`src/bin/huginn/`), the shell door every agent uses, and until
|
||||
# delete-eureka-state-mcp the MCP server (`main.rs`, the `eureka-state` binary).
|
||||
# Both hold no state.
|
||||
# `cli` (default) is the `huginn` binary's argument parser; a library consumer
|
||||
# takes `default-features = false` and compiles none of it.
|
||||
[features]
|
||||
default = ["cli"]
|
||||
cli = ["dep:clap"]
|
||||
|
||||
[[bin]]
|
||||
name = "huginn"
|
||||
path = "src/bin/huginn/main.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[[test]]
|
||||
name = "cli"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
cultnet-rs.workspace = true
|
||||
huginn-mind = { path = "../huginn-mind" }
|
||||
rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] }
|
||||
|
|
|
|||
67
crates/eureka-state/src/bin/huginn/config.rs
Normal file
67
crates/eureka-state/src/bin/huginn/config.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! The CLI's configuration: the mind is the session's environment, never an
|
||||
//! argument. `EUREKA_INSTANCE` names the instance and `HUGINN_ENDPOINT`
|
||||
//! (`rudp://<ip>:<port>`) the daemon that holds it.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use eureka_state::HuginnClient;
|
||||
use huginn_mind::eureka_pipeline::Slug;
|
||||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse};
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The `provenance.tool` every admission from this binary carries.
|
||||
pub const TOOL: &str = "huginn";
|
||||
/// How long one call may take, connection to last byte.
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The two environment values as given, and the client they make when both
|
||||
/// are valid.
|
||||
pub struct Settings {
|
||||
pub instance: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
client: Result<HuginnClient, String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_env() -> Self {
|
||||
let instance = variable("EUREKA_INSTANCE");
|
||||
let endpoint = variable("HUGINN_ENDPOINT");
|
||||
let shown = |value: &Result<Option<String>, String>| value.as_ref().ok().cloned().flatten();
|
||||
let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint));
|
||||
Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) }
|
||||
}
|
||||
|
||||
pub fn client(&self) -> Result<&HuginnClient, Trouble> {
|
||||
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
|
||||
}
|
||||
|
||||
/// One wire call for the configured instance. The request is built from
|
||||
/// that instance, never from anything the caller sent. No retry.
|
||||
pub fn ask(&self, request: impl FnOnce(&Slug) -> HuginnMindRequest) -> Result<HuginnMindResponse, Trouble> {
|
||||
let client = self.client()?;
|
||||
client.call(request(client.instance())).map_err(Trouble::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// An environment variable: its value, `None` when unset, or why it cannot be read.
|
||||
fn variable(name: &str) -> Result<Option<String>, String> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(instance: Result<Option<String>, String>, endpoint: Result<Option<String>, String>) -> Result<HuginnClient, String> {
|
||||
let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?;
|
||||
let slug = Slug(instance);
|
||||
slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?;
|
||||
let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?;
|
||||
let address = endpoint
|
||||
.strip_prefix("rudp://")
|
||||
.and_then(|address| address.parse::<SocketAddr>().ok())
|
||||
.ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://<ip>:<port>: {endpoint}"))?;
|
||||
Ok(HuginnClient::new(address, slug, TIMEOUT))
|
||||
}
|
||||
204
crates/eureka-state/src/bin/huginn/door.rs
Normal file
204
crates/eureka-state/src/bin/huginn/door.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//! The text door: agent-written JSON becomes typed values here, strictly, and
|
||||
//! nowhere else. The store's decode stays lenient (serde ignores unknown
|
||||
//! keys), so a misspelled key would be dropped and the batch committed
|
||||
//! without it. The door refuses instead: every key path present in the input
|
||||
//! with a value and absent from the typed value's re-serialisation is named in
|
||||
//! the refusal. Paths only, never values.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use cultnet_rs::Selection;
|
||||
use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, error::Category};
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The text of an argument that is a literal, `@<file>` or `-` for stdin.
|
||||
pub fn argument(arg: &str) -> Result<String, Trouble> {
|
||||
match arg.strip_prefix('@') {
|
||||
Some(path) => file(path),
|
||||
None if arg == "-" => stdin(),
|
||||
None => Ok(arg.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a file argument, or stdin when it is `-`.
|
||||
pub fn file_or_stdin(arg: &str) -> Result<String, Trouble> {
|
||||
if arg == "-" { stdin() } else { file(arg) }
|
||||
}
|
||||
|
||||
fn file(path: &str) -> Result<String, Trouble> {
|
||||
std::fs::read_to_string(path).map_err(|error| Trouble::invalid(format!("cannot read {path}: {}", error.kind())))
|
||||
}
|
||||
|
||||
fn stdin() -> Result<String, Trouble> {
|
||||
let mut text = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| Trouble::invalid(format!("cannot read stdin: {}", error.kind())))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// A batch: a JSON array of `{kind, value}` documents, or one such object.
|
||||
pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Trouble> {
|
||||
let value = parse(text)?;
|
||||
let (items, typed): (&[Value], Vec<PipelineDocument>) = match &value {
|
||||
Value::Array(items) => (items.as_slice(), objects(items).and_then(|()| from_text(text, "documents"))?),
|
||||
Value::Object(_) => (std::slice::from_ref(&value), vec![from_text(text, "documents[0]")?]),
|
||||
_ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")),
|
||||
};
|
||||
let kept = reserialise(&typed, "documents")?;
|
||||
let mut lost = Vec::new();
|
||||
for (index, (given, held)) in items.iter().zip(&kept).enumerate() {
|
||||
dropped(given, held, &format!("documents[{index}]"), &mut lost);
|
||||
}
|
||||
refuse_lost(&lost)?;
|
||||
Ok(typed)
|
||||
}
|
||||
|
||||
pub fn selection(text: &str) -> Result<Selection, Trouble> {
|
||||
let value = parse(text)?;
|
||||
if !value.is_object() {
|
||||
return Err(Trouble::invalid("selection must be a JSON object"));
|
||||
}
|
||||
let typed: Selection = from_text(text, "selection")?;
|
||||
let kept = reserialise(std::slice::from_ref(&typed), "selection")?;
|
||||
let mut lost = Vec::new();
|
||||
dropped(&value, &kept[0], "selection", &mut lost);
|
||||
refuse_lost(&lost)?;
|
||||
Ok(typed)
|
||||
}
|
||||
|
||||
pub fn selection_value(value: &Value) -> Result<Selection, Trouble> {
|
||||
selection(&value.to_string())
|
||||
}
|
||||
|
||||
/// The kind a pipeline id names in its middle segment (`<root>:<kind>:<local>`).
|
||||
pub fn kind_of(id: &str) -> Result<PipelineKind, Trouble> {
|
||||
let mut segments = id.splitn(3, ':');
|
||||
let (_, Some(name), Some(_)) = (segments.next(), segments.next(), segments.next()) else {
|
||||
return Err(Trouble::invalid("a pipeline id is <root>:<kind>:<local>"));
|
||||
};
|
||||
PipelineKind::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|kind| kind.name() == name)
|
||||
.ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)"))
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Result<Value, Trouble> {
|
||||
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {}", place(&error))))
|
||||
}
|
||||
|
||||
/// Where and what kind of failure a decode hit, never what the input said:
|
||||
/// serde's own message quotes the offending value whole.
|
||||
fn place(error: &serde_json::Error) -> String {
|
||||
let category = match error.classify() {
|
||||
Category::Io => "unreadable input",
|
||||
Category::Syntax => "syntax",
|
||||
Category::Data => "a value of the wrong type, an unknown variant or a missing field",
|
||||
Category::Eof => "unexpected end",
|
||||
};
|
||||
format!("{category} at line {} column {}", error.line(), error.column())
|
||||
}
|
||||
|
||||
/// Serde would also read a struct from an array, positionally, so only an
|
||||
/// object is a document or a selection.
|
||||
fn objects(items: &[Value]) -> Result<(), Trouble> {
|
||||
match items.iter().position(|item| !item.is_object()) {
|
||||
None => Ok(()),
|
||||
Some(index) => Err(Trouble::invalid(format!("documents[{index}] must be a JSON object"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The typed value of the text, decoded from the text itself so the failure
|
||||
/// has a real line and column.
|
||||
fn from_text<T: DeserializeOwned>(text: &str, at: &str) -> Result<T, Trouble> {
|
||||
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("{at} does not decode: {}", place(&error))))
|
||||
}
|
||||
|
||||
/// What the typed values hold, as the JSON items to compare with the input.
|
||||
fn reserialise<T: Serialize>(typed: &[T], at: &str) -> Result<Vec<Value>, Trouble> {
|
||||
typed
|
||||
.iter()
|
||||
.map(|item| serde_json::to_value(item).map_err(|_| Trouble::Internal(format!("{at} does not re-serialise"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refuse_lost(paths: &[String]) -> Result<(), Trouble> {
|
||||
if paths.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", "))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths of `input` that carry a value and are absent from `kept`.
|
||||
fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec<String>) {
|
||||
match (input, kept) {
|
||||
(Value::Object(given), Value::Object(held)) => {
|
||||
for (key, value) in given {
|
||||
let here = format!("{path}.{key}");
|
||||
match held.get(key) {
|
||||
Some(held) => dropped(value, held, &here, out),
|
||||
None if !empty(value) => out.push(here),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(given), Value::Array(held)) => {
|
||||
for (index, (value, held)) in given.iter().zip(held).enumerate() {
|
||||
dropped(value, held, &format!("{path}[{index}]"), out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value whose loss changes nothing: the spellings of "not given".
|
||||
fn empty(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => true,
|
||||
Value::String(text) => text.is_empty(),
|
||||
Value::Array(items) => items.is_empty(),
|
||||
Value::Object(fields) => fields.is_empty(),
|
||||
Value::Bool(_) | Value::Number(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn paths(input: &Value, kept: &Value) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
dropped(input, kept, "d", &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_key_with_a_value_is_named_by_path() {
|
||||
let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 });
|
||||
let kept = json!({ "a": { "b": [ { "c": 1 } ] } });
|
||||
assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_key_without_a_value_is_not_a_loss() {
|
||||
let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false });
|
||||
assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_come_from_the_middle_segment() {
|
||||
assert_eq!(kind_of("eureka-substrate:cut_spec:cut-x.r1").ok(), Some(PipelineKind::CutSpec));
|
||||
assert!(kind_of("eureka-substrate:nonsense:x").is_err());
|
||||
assert!(kind_of("two:segments").is_err());
|
||||
assert!(kind_of("campaign").is_err());
|
||||
}
|
||||
}
|
||||
399
crates/eureka-state/src/bin/huginn/main.rs
Normal file
399
crates/eureka-state/src/bin/huginn/main.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
//! `huginn`: the agents' door to Huginn's mind, as shell commands.
|
||||
//!
|
||||
//! A caller of `HuginnClient` and nothing else. It owns argument parsing, the
|
||||
//! 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`
|
||||
//! 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
|
||||
//! caller's rule retries `Unavailable` once.
|
||||
//!
|
||||
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
|
||||
//! 1 the mind said no (a refusal, a conflict, an absent document). Troubles go
|
||||
//! to stderr as `<Code>: <detail>`: 2 the call is wrong, 3 Unavailable, 4 no
|
||||
//! answer and a retry cannot help, 5 the read-back after a committed admission
|
||||
//! differs from what was sent.
|
||||
|
||||
mod config;
|
||||
mod door;
|
||||
mod recipes;
|
||||
mod render;
|
||||
mod schema;
|
||||
mod trouble;
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use cultnet_rs::{PROJECTION_DOCUMENT, Selection};
|
||||
use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key};
|
||||
use huginn_mind::{
|
||||
BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome,
|
||||
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use config::{Settings, TOOL};
|
||||
use recipes::Recipe;
|
||||
use trouble::Trouble;
|
||||
|
||||
/// The page size when a selection names none.
|
||||
const DEFAULT_LIMIT: u32 = 50;
|
||||
/// The neighbours `--near` asks for when `--top-k` is not given.
|
||||
const DEFAULT_TOP_K: u32 = 10;
|
||||
|
||||
const ADMIT_HELP: &str = "\
|
||||
The file is a JSON array of 1 to 64 documents, each {\"kind\": <kind>, \"value\": {...}}, or - for stdin; one document object alone is a batch of one. \
|
||||
`huginn schema` lists the kinds and `huginn schema <kind>` prints a value's JSON Schema. \
|
||||
Every key the schema does not know is refused (exit 2) with its path; nothing is sent. \
|
||||
After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back <n> equal`, or exit 5 with the ids and field paths that differ.";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, admit, schema", arg_required_else_help = true)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Who this process acts for and whether the daemon answers.
|
||||
Whoami,
|
||||
/// Read one document by id, with its admission facts and status.
|
||||
View {
|
||||
/// The document's id: <root>:<kind>:<local>.
|
||||
id: String,
|
||||
/// Print only the {kind, value} JSON, which `admit` accepts back.
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
},
|
||||
/// Query the mind with a CultNet selection.
|
||||
Query {
|
||||
/// The selection JSON, @<file>, or - for stdin.
|
||||
selection: String,
|
||||
/// Rank by nearness to this text instead of ordering by key.
|
||||
#[arg(long)]
|
||||
near: Option<String>,
|
||||
/// How many neighbours (default 10).
|
||||
#[arg(long, requires = "near")]
|
||||
top_k: Option<u32>,
|
||||
/// Print each item as `view` does.
|
||||
#[arg(long)]
|
||||
documents: bool,
|
||||
},
|
||||
#[command(flatten)]
|
||||
Recipe(Recipe),
|
||||
/// Admit a batch of documents, whole or not at all, and read it back.
|
||||
#[command(after_help = ADMIT_HELP)]
|
||||
Admit {
|
||||
#[arg(long, value_parser = faculty)]
|
||||
faculty: Faculty,
|
||||
/// The admitting agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The admitting session.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
/// The documents file, or - for stdin.
|
||||
file: String,
|
||||
},
|
||||
/// The JSON Schema of a document kind or of a selection; no name lists them.
|
||||
Schema { name: Option<String> },
|
||||
}
|
||||
|
||||
fn faculty(text: &str) -> Result<Faculty, String> {
|
||||
serde_json::from_value(Value::String(text.to_string())).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// The version, then the sha the binary was built from.
|
||||
fn version() -> &'static str {
|
||||
static VERSION: OnceLock<String> = OnceLock::new();
|
||||
VERSION.get_or_init(|| format!("{} ({})", env!("CARGO_PKG_VERSION"), build_sha()))
|
||||
}
|
||||
|
||||
fn build_sha() -> &'static str {
|
||||
option_env!("HUGINN_BUILD_SHA").unwrap_or("unknown")
|
||||
}
|
||||
|
||||
/// The command line, or the exit code of having answered it (help, version) or
|
||||
/// refused it (usage, as `InvalidInput`).
|
||||
fn parse() -> Result<Cli, u8> {
|
||||
let mut command = Cli::command().version(version());
|
||||
for (name, text) in recipes::help() {
|
||||
command = command.mut_subcommand(name, |recipe| recipe.after_help(text));
|
||||
}
|
||||
match command.try_get_matches() {
|
||||
Ok(matches) => Cli::from_arg_matches(&matches).map_err(|error| usage(&error)),
|
||||
Err(error) => Err(usage(&error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage(error: &clap::Error) -> u8 {
|
||||
if error.use_stderr() {
|
||||
let text = error.to_string();
|
||||
eprintln!("InvalidInput: {}", text.trim_start_matches("error: ").trim_end());
|
||||
2
|
||||
} else {
|
||||
let _ = error.print();
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = match parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(code) => return ExitCode::from(code),
|
||||
};
|
||||
match run(&Settings::from_env(), cli.command) {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(trouble) => {
|
||||
eprintln!("{trouble}");
|
||||
ExitCode::from(trouble.exit())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn say(text: impl std::fmt::Display) {
|
||||
let _ = writeln!(std::io::stdout(), "{text}");
|
||||
}
|
||||
|
||||
fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
|
||||
match command {
|
||||
Command::Whoami => whoami(settings),
|
||||
Command::View { id, raw } => view(settings, &id, raw),
|
||||
Command::Query { selection, near, top_k, documents } => {
|
||||
let selection = door::selection(&door::argument(&selection)?)?;
|
||||
let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) });
|
||||
query(settings, selection, semantic, None, documents)
|
||||
}
|
||||
Command::Recipe(recipe) => {
|
||||
let ask = recipe.ask()?;
|
||||
query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents)
|
||||
}
|
||||
Command::Admit { faculty, agent, session, file } => admit(settings, faculty, agent, session, &file),
|
||||
Command::Schema { name } => {
|
||||
let text = schema::text(name.as_deref())?;
|
||||
let _ = write!(std::io::stdout(), "{text}");
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn whoami(settings: &Settings) -> Result<u8, Trouble> {
|
||||
say(format_args!("instance {}", settings.instance.as_deref().unwrap_or("unset")));
|
||||
say(format_args!("endpoint {}", settings.endpoint.as_deref().unwrap_or("unset")));
|
||||
say(format_args!("client {}", build_sha()));
|
||||
match settings.ask(|_| HuginnMindRequest::Whoami) {
|
||||
Ok(HuginnMindResponse::Whoami(status)) => {
|
||||
say("reachable yes");
|
||||
say(format_args!(
|
||||
"documents {} receipts {} index {} epoch {}",
|
||||
status.documents,
|
||||
status.receipts,
|
||||
render::index_state(&status.index),
|
||||
status.schema_epoch
|
||||
));
|
||||
say(format_args!("persona {}", render::persona_state(&status.persona)));
|
||||
Ok(0)
|
||||
}
|
||||
Ok(other) => {
|
||||
say("reachable no");
|
||||
Err(unexpected(&other))
|
||||
}
|
||||
Err(trouble) => {
|
||||
say("reachable no");
|
||||
Err(trouble)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(settings: &Settings, id: &str, raw: bool) -> Result<u8, Trouble> {
|
||||
let reference = PipelineRef { kind: door::kind_of(id)?, id: Short(id.to_string()) };
|
||||
match settings.ask(|instance| HuginnMindRequest::View { instance: instance.clone(), id: reference })? {
|
||||
HuginnMindResponse::View(Some(found)) => {
|
||||
say(render::view(&found, raw));
|
||||
Ok(0)
|
||||
}
|
||||
HuginnMindResponse::View(None) => {
|
||||
say(format_args!("absent {id}"));
|
||||
Ok(1)
|
||||
}
|
||||
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn query(
|
||||
settings: &Settings,
|
||||
mut selection: Selection,
|
||||
semantic: Option<SemanticQuery>,
|
||||
cursor: Option<String>,
|
||||
documents: bool,
|
||||
) -> Result<u8, Trouble> {
|
||||
if documents {
|
||||
selection.projection = PROJECTION_DOCUMENT.to_string();
|
||||
}
|
||||
if cursor.is_some() {
|
||||
selection.cursor = cursor;
|
||||
}
|
||||
selection.limit.get_or_insert(DEFAULT_LIMIT);
|
||||
match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic })? {
|
||||
HuginnMindResponse::Query(page) => {
|
||||
show(&page);
|
||||
Ok(0)
|
||||
}
|
||||
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn show(page: &PipelineSelectionPage) {
|
||||
match &page.items {
|
||||
PipelinePageItems::Headers(headers) => {
|
||||
for header in headers {
|
||||
say(render::header(header));
|
||||
}
|
||||
}
|
||||
PipelinePageItems::Documents(views) => {
|
||||
for (index, found) in views.iter().enumerate() {
|
||||
if index > 0 {
|
||||
say("");
|
||||
}
|
||||
say(render::view(found, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
say(format_args!("matched {} as_of {} next {}", page.matched, page.as_of, page.next.as_deref().unwrap_or("-")));
|
||||
}
|
||||
|
||||
fn admit(settings: &Settings, faculty: Faculty, agent: String, session: String, file: &str) -> Result<u8, Trouble> {
|
||||
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())));
|
||||
}
|
||||
let sent = documents.clone();
|
||||
let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) };
|
||||
let request = |instance: &Slug| {
|
||||
HuginnMindRequest::Admit(PipelineAdmissionBatch { instance: instance.clone(), provenance, documents })
|
||||
};
|
||||
match settings.ask(request)? {
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id, committed_at, writes }) => {
|
||||
say(format_args!("Committed {receipt_id} at {committed_at}"));
|
||||
for write in &writes {
|
||||
say(format_args!("wrote {}", write.id.0));
|
||||
}
|
||||
read_back(settings, &sent)
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }) => {
|
||||
say(format_args!("AlreadyAdmitted {receipt_id}"));
|
||||
read_back(settings, &sent)
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => {
|
||||
Ok(refused(&refusal))
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities }) => {
|
||||
say("Conflict");
|
||||
for identity in &identities {
|
||||
say(format_args!("conflicts {}", identity.id.0));
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The end-to-end check that the mind kept what was sent: one `keys` query,
|
||||
/// each stored document compared with the one sent. A difference is exit 5,
|
||||
/// not silence, whatever dropped it (an older leaf, a serde default, skew).
|
||||
fn read_back(settings: &Settings, sent: &[PipelineDocument]) -> Result<u8, Trouble> {
|
||||
let keys = sent
|
||||
.iter()
|
||||
.map(|document| pipeline_key(document).map_err(|refusal| Trouble::Internal(format!("a committed document has no key: {refusal:?}"))))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let selection = Selection {
|
||||
keys: Some(keys.clone()),
|
||||
projection: PROJECTION_DOCUMENT.to_string(),
|
||||
limit: Some(u32::try_from(keys.len()).unwrap_or(u32::MAX)),
|
||||
..Selection::default()
|
||||
};
|
||||
let page = match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic: None })? {
|
||||
HuginnMindResponse::Query(page) => page,
|
||||
HuginnMindResponse::Refused(refusal) => return Ok(refused(&refusal)),
|
||||
other => return Err(unexpected(&other)),
|
||||
};
|
||||
let PipelinePageItems::Documents(stored) = page.items else {
|
||||
return Err(Trouble::Internal("the read-back answered headers, not documents".to_string()));
|
||||
};
|
||||
let mut differing = Vec::new();
|
||||
for (key, document) in keys.iter().zip(sent) {
|
||||
let held = stored.iter().find(|found| &found.id.id.0 == key);
|
||||
match held {
|
||||
None => differing.push(format!("{key}: missing from the read-back")),
|
||||
Some(found) if found.document == *document => {}
|
||||
Some(found) => {
|
||||
let (mut paths, sent_value, held_value) = (Vec::new(), to_value(document), to_value(&found.document));
|
||||
differences(&sent_value, &held_value, "", &mut paths);
|
||||
let shown = if paths.is_empty() { "the document".to_string() } else { paths.join(", ") };
|
||||
differing.push(format!("{key}: {shown}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if differing.is_empty() {
|
||||
say(format_args!("read back {} equal", sent.len()));
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(Trouble::ReadBackDiffers(format!(
|
||||
"{}; the batch is committed, so supersede the documents that differ",
|
||||
differing.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(document: &PipelineDocument) -> Value {
|
||||
serde_json::to_value(document).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The field paths at which two documents differ, including a field only one
|
||||
/// of them carries.
|
||||
fn differences(sent: &Value, held: &Value, path: &str, out: &mut Vec<String>) {
|
||||
match (sent, held) {
|
||||
(Value::Object(sent), Value::Object(held)) => {
|
||||
for key in sent.keys().chain(held.keys().filter(|key| !sent.contains_key(*key))) {
|
||||
let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
|
||||
match (sent.get(key), held.get(key)) {
|
||||
(Some(sent), Some(held)) => differences(sent, held, &here, out),
|
||||
_ => out.push(here),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(sent), Value::Array(held)) if sent.len() == held.len() => {
|
||||
for (index, (sent, held)) in sent.iter().zip(held).enumerate() {
|
||||
differences(sent, held, &format!("{path}[{index}]"), out);
|
||||
}
|
||||
}
|
||||
_ if sent == held => {}
|
||||
_ => out.push(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal is the mind's answer: stdout, exit 1.
|
||||
fn refused(refusal: &MindRefusal) -> u8 {
|
||||
say(format_args!("Refused {refusal:?}"));
|
||||
1
|
||||
}
|
||||
|
||||
fn unexpected(answer: &HuginnMindResponse) -> Trouble {
|
||||
let name = match answer {
|
||||
HuginnMindResponse::Whoami(_) => "whoami",
|
||||
HuginnMindResponse::Admit(_) => "admit",
|
||||
HuginnMindResponse::View(_) => "view",
|
||||
HuginnMindResponse::Query(_) => "query",
|
||||
HuginnMindResponse::Persona(_) => "a persona",
|
||||
HuginnMindResponse::PersonaPut(_) => "a persona put",
|
||||
HuginnMindResponse::Refused(_) => "a refusal",
|
||||
HuginnMindResponse::Deferred(_) => "a deferral",
|
||||
};
|
||||
Trouble::Internal(format!("the daemon answered with {name}, which this call did not ask for"))
|
||||
}
|
||||
287
crates/eureka-state/src/bin/huginn/recipes.rs
Normal file
287
crates/eureka-state/src/bin/huginn/recipes.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! The campaign progress view as named subcommands. Each recipe is exactly the
|
||||
//! selection `references/campaign-state.md` gives (Eureka 278b881, "Recipes"),
|
||||
//! built per call and never cached. Each one's `--help` prints its selection.
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use huginn_mind::SemanticQuery;
|
||||
use huginn_mind::eureka_pipeline::{Line, PipelineKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::door;
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The campaign whose `gap-` follow-ups are the substrate's known gaps.
|
||||
const GAP_HOME: &str = "eureka-substrate";
|
||||
/// How many neighbours `precedent` asks the index for.
|
||||
const PRECEDENT_TOP_K: u32 = 10;
|
||||
/// Severities that block a cut.
|
||||
const BLOCKING: [&str; 2] = ["Blocker", "High"];
|
||||
|
||||
#[derive(Args, Clone, Debug, Default)]
|
||||
pub struct Paging {
|
||||
/// The `next` cursor of the previous page.
|
||||
#[arg(long)]
|
||||
pub cursor: Option<String>,
|
||||
/// Print each item as `view` does, not as a header line.
|
||||
#[arg(long)]
|
||||
pub documents: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Recipe {
|
||||
/// Every campaign.
|
||||
Campaigns(Paging),
|
||||
/// The target in force.
|
||||
Target {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Rulings in force, operator directions included.
|
||||
Rulings {
|
||||
#[arg(required = true)]
|
||||
campaigns: Vec<String>,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open questions and follow-ups.
|
||||
Open {
|
||||
#[arg(required = true)]
|
||||
campaigns: Vec<String>,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open findings admitted by Soul.
|
||||
Findings {
|
||||
campaign: String,
|
||||
/// Only Blocker and High.
|
||||
#[arg(long)]
|
||||
blocking: bool,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Specs with no report.
|
||||
Unreported {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open questions raised in a spec: the spec waits on the operator.
|
||||
Blocked {
|
||||
spec_id: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Reports with no verdict: Soul's queue.
|
||||
Unjudged {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// A subject's resolutions, newest first.
|
||||
History {
|
||||
id: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// One cut's spec, reports, verdicts, findings and resolutions.
|
||||
Cut {
|
||||
campaign: String,
|
||||
label: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Every spec and report, whole.
|
||||
Ledger {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Rulings, resolutions and findings nearest a question in plain words.
|
||||
Precedent {
|
||||
campaign: String,
|
||||
text: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// The substrate's known gaps.
|
||||
Gaps(Paging),
|
||||
}
|
||||
|
||||
/// What a recipe asks the mind: the selection, the semantic part when it has
|
||||
/// one, and the caller's paging flags.
|
||||
pub struct Ask {
|
||||
pub selection: Value,
|
||||
pub semantic: Option<SemanticQuery>,
|
||||
pub page: Paging,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
pub fn ask(self) -> Result<Ask, Trouble> {
|
||||
let (selection, semantic, page) = match self {
|
||||
Self::Campaigns(page) => (campaigns(), None, page),
|
||||
Self::Target { campaign, page } => (target(&campaign), None, page),
|
||||
Self::Rulings { campaigns, page } => (rulings(&campaigns), None, page),
|
||||
Self::Open { campaigns, page } => (open(&campaigns), None, page),
|
||||
Self::Findings { campaign, blocking, page } => (findings(&campaign, blocking), None, page),
|
||||
Self::Unreported { campaign, page } => (unreported(&campaign), None, page),
|
||||
Self::Blocked { spec_id, page } => (blocked(&spec_id), None, page),
|
||||
Self::Unjudged { campaign, page } => (unjudged(&campaign), None, page),
|
||||
Self::History { id, page } => (history(door::kind_of(&id)?.type_id(), &id), None, page),
|
||||
Self::Cut { campaign, label, page } => (cut(&campaign, &label), None, page),
|
||||
Self::Ledger { campaign, page } => (ledger(&campaign), None, page),
|
||||
Self::Precedent { campaign, text, page } => {
|
||||
(precedent(&campaign), Some(SemanticQuery { text: Line(text), top_k: PRECEDENT_TOP_K }), page)
|
||||
}
|
||||
Self::Gaps(page) => (gaps(), None, page),
|
||||
};
|
||||
Ok(Ask { selection, semantic, page })
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(kind: PipelineKind) -> &'static str {
|
||||
kind.type_id()
|
||||
}
|
||||
|
||||
fn root(campaigns: &[String]) -> Value {
|
||||
json!({ "index": "root", "op": "any_of", "values": campaigns })
|
||||
}
|
||||
|
||||
fn in_force() -> Value {
|
||||
json!({ "index": "in_force", "op": "any_of", "values": ["true"] })
|
||||
}
|
||||
|
||||
fn severity_blocking() -> Value {
|
||||
json!({ "index": "severity", "op": "any_of", "values": BLOCKING })
|
||||
}
|
||||
|
||||
fn campaigns() -> Value {
|
||||
json!({ "schemas": [schema(PipelineKind::Campaign)] })
|
||||
}
|
||||
|
||||
fn target(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Target)],
|
||||
"fields": [root(&[campaign.to_string()]), in_force()],
|
||||
"projection": "document",
|
||||
})
|
||||
}
|
||||
|
||||
fn rulings(campaigns: &[String]) -> Value {
|
||||
json!({ "schemas": [schema(PipelineKind::Ruling)], "fields": [root(campaigns), in_force()] })
|
||||
}
|
||||
|
||||
fn open(campaigns: &[String]) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Question), schema(PipelineKind::FollowUp)],
|
||||
"fields": [root(campaigns), in_force()],
|
||||
})
|
||||
}
|
||||
|
||||
fn findings(campaign: &str, blocking: bool) -> Value {
|
||||
let mut fields = vec![
|
||||
root(&[campaign.to_string()]),
|
||||
in_force(),
|
||||
json!({ "index": "faculty", "op": "any_of", "values": ["Soul"] }),
|
||||
];
|
||||
if blocking {
|
||||
fields.push(severity_blocking());
|
||||
}
|
||||
json!({ "schemas": [schema(PipelineKind::Finding)], "fields": fields })
|
||||
}
|
||||
|
||||
fn unreported(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::CutSpec)],
|
||||
"fields": [root(&[campaign.to_string()]), in_force()],
|
||||
"cited": { "role": "cut_spec", "exists": false },
|
||||
})
|
||||
}
|
||||
|
||||
fn blocked(spec_id: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Question)],
|
||||
"fields": [in_force()],
|
||||
"cites": { "target": { "schemaId": schema(PipelineKind::CutSpec), "recordKey": spec_id }, "role": "raised_in" },
|
||||
})
|
||||
}
|
||||
|
||||
fn unjudged(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::CutReport)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
"cited": { "role": "cut_report", "exists": false },
|
||||
})
|
||||
}
|
||||
|
||||
fn history(schema_id: &str, id: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Resolution)],
|
||||
"cites": { "target": { "schemaId": schema_id, "recordKey": id }, "role": "subject" },
|
||||
"descending": true,
|
||||
})
|
||||
}
|
||||
|
||||
fn cut(campaign: &str, label: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [
|
||||
schema(PipelineKind::CutSpec),
|
||||
schema(PipelineKind::CutReport),
|
||||
schema(PipelineKind::Verdict),
|
||||
schema(PipelineKind::Finding),
|
||||
schema(PipelineKind::Resolution),
|
||||
],
|
||||
"fields": [root(&[campaign.to_string()]), { "index": "cut", "op": "any_of", "values": [label] }],
|
||||
})
|
||||
}
|
||||
|
||||
fn ledger(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::CutSpec), schema(PipelineKind::CutReport)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
"projection": "document",
|
||||
})
|
||||
}
|
||||
|
||||
fn precedent(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Ruling), schema(PipelineKind::Resolution), schema(PipelineKind::Finding)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
})
|
||||
}
|
||||
|
||||
fn gaps() -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::FollowUp)],
|
||||
"fields": [root(&[GAP_HOME.to_string()]), in_force()],
|
||||
})
|
||||
}
|
||||
|
||||
/// Each recipe's `--help` addendum: its selection with placeholders in the
|
||||
/// arguments' places. The same builders as execution, so help cannot drift
|
||||
/// from what runs.
|
||||
pub fn help() -> Vec<(&'static str, String)> {
|
||||
let c = || "<c>".to_string();
|
||||
let selection = |value: Value| format!("selection:\n{}", serde_json::to_string_pretty(&value).unwrap_or_default());
|
||||
let findings_help =
|
||||
format!("{}\n--blocking adds the field predicate:\n{}", selection(findings(&c(), false)), severity_blocking());
|
||||
let precedent_help = format!(
|
||||
"{}\nsemantic: {{ \"text\": \"<text>\", \"top_k\": {PRECEDENT_TOP_K} }}",
|
||||
selection(precedent(&c()))
|
||||
);
|
||||
vec![
|
||||
("campaigns", selection(campaigns())),
|
||||
("target", selection(target(&c()))),
|
||||
("rulings", selection(rulings(&[c()]))),
|
||||
("open", selection(open(&[c()]))),
|
||||
("findings", findings_help),
|
||||
("unreported", selection(unreported(&c()))),
|
||||
("blocked", selection(blocked("<id>"))),
|
||||
("unjudged", selection(unjudged(&c()))),
|
||||
("history", selection(history("epiphany.pipeline.<kind>.v2", "<id>"))),
|
||||
("cut", selection(cut(&c(), "<label>"))),
|
||||
("ledger", selection(ledger(&c()))),
|
||||
("precedent", precedent_help),
|
||||
("gaps", selection(gaps())),
|
||||
]
|
||||
}
|
||||
137
crates/eureka-state/src/bin/huginn/render.rs
Normal file
137
crates/eureka-state/src/bin/huginn/render.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Text for stdout. Display only: nothing here decides an answer.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use huginn_mind::{IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A header line is cut here, so a page of them stays one screen per dozen.
|
||||
const HEADER_MAX_CHARS: usize = 240;
|
||||
|
||||
/// The variant or string a serde-tagged value is spelled as: `Hands` for a
|
||||
/// faculty, `Fixed` for an outcome.
|
||||
pub fn name_of<T: Serialize>(value: &T) -> String {
|
||||
match serde_json::to_value(value) {
|
||||
Ok(Value::String(name)) => name,
|
||||
Ok(Value::Object(fields)) => fields.keys().next().cloned().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The persona door's state as one line: no value of the document, only the
|
||||
/// vocabulary the door answers in.
|
||||
pub fn persona_state(persona: &PersonaStatus) -> String {
|
||||
match persona {
|
||||
PersonaStatus::Absent => "absent".into(),
|
||||
PersonaStatus::Stored { updated_at } => format!("stored updatedAt={updated_at}"),
|
||||
PersonaStatus::Unreadable => "unreadable".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An index status as one token and its numbers: `Behind pending=3`.
|
||||
pub fn index_state(index: &IndexStatus) -> String {
|
||||
let mut text = name_of(index);
|
||||
if let Ok(Value::Object(variant)) = serde_json::to_value(index)
|
||||
&& let Some(Value::Object(fields)) = variant.values().next()
|
||||
{
|
||||
for (key, value) in fields {
|
||||
if let Some(shown) = scalar(value) {
|
||||
let _ = write!(text, " {key}={shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// One document as `view` prints it: id and status, the admission facts, then
|
||||
/// the `{kind, value}` document as pretty JSON. `raw` is the JSON alone.
|
||||
pub fn view(view: &PipelineDocumentView, raw: bool) -> String {
|
||||
let document = serde_json::to_string_pretty(&view.document).unwrap_or_default();
|
||||
if raw {
|
||||
return document;
|
||||
}
|
||||
let id = &view.id.id.0;
|
||||
let status = match &view.status {
|
||||
PipelineStatus::InForce => "InForce".to_string(),
|
||||
PipelineStatus::Resolved { resolution, record } => format!("Resolved {} by {}", name_of(&record.outcome), resolution.id.0),
|
||||
};
|
||||
let admission = &view.admission;
|
||||
let who = &admission.provenance;
|
||||
format!(
|
||||
"{id} {status}\nadmitted {} ordinal {} {}/{} session {} tool {} receipt {}\n{document}",
|
||||
admission.admitted_at,
|
||||
admission.ordinal,
|
||||
name_of(&who.faculty),
|
||||
who.agent.0,
|
||||
who.session.0,
|
||||
who.tool.0,
|
||||
admission.receipt_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// One header as a line: `<id> <status> <faculty> <title or label> k=v...`
|
||||
/// over the header's scalar facts.
|
||||
pub fn header(item: &PipelineDocumentSummary) -> String {
|
||||
let status = match &item.status {
|
||||
PipelineStatusSummary::InForce => "InForce".to_string(),
|
||||
PipelineStatusSummary::Resolved { outcome, .. } => format!("Resolved:{}", name_of(outcome)),
|
||||
};
|
||||
let mut line = format!("{} {status} {}", item.id.id.0, name_of(&item.admission.provenance.faculty));
|
||||
if let Ok(facts) = serde_json::to_value(&item.facts)
|
||||
&& let Some(Value::Object(facts)) = facts.get("value")
|
||||
{
|
||||
if let Some(Value::String(name)) = facts.get("title").or_else(|| facts.get("label")) {
|
||||
let _ = write!(line, " {name}");
|
||||
}
|
||||
for (key, value) in facts {
|
||||
if key != "title"
|
||||
&& key != "label"
|
||||
&& let Some(shown) = scalar(value)
|
||||
{
|
||||
let _ = write!(line, " {key}={shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
line.chars().take(HEADER_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// A fact as one token: a string, a number, a flag, a reference's id, or a
|
||||
/// list of those joined by commas. Anything deeper is not a header fact.
|
||||
fn scalar(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) if !text.is_empty() => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
Value::Array(items) => {
|
||||
let shown: Vec<String> = items.iter().filter_map(scalar).collect();
|
||||
if shown.is_empty() { None } else { Some(shown.join(",")) }
|
||||
}
|
||||
Value::Object(fields) => match fields.get("id") {
|
||||
Some(Value::String(id)) => Some(id.clone()),
|
||||
_ => None,
|
||||
},
|
||||
Value::String(_) | Value::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fact_is_one_token_or_nothing() {
|
||||
assert_eq!(scalar(&json!("")), None);
|
||||
assert_eq!(scalar(&json!("a b")), Some("a b".to_string()));
|
||||
assert_eq!(scalar(&json!(3)), Some("3".to_string()));
|
||||
assert_eq!(scalar(&json!(true)), Some("true".to_string()));
|
||||
assert_eq!(scalar(&json!(["a", "", "b"])), Some("a,b".to_string()));
|
||||
assert_eq!(scalar(&json!([])), None);
|
||||
assert_eq!(scalar(&json!(null)), None);
|
||||
assert_eq!(scalar(&json!({ "kind": "Ruling", "id": "c:ruling:R1" })), Some("c:ruling:R1".to_string()));
|
||||
assert_eq!(scalar(&json!({ "id": 3 })), None);
|
||||
assert_eq!(scalar(&json!({ "Withdrawn": { "reason": "moot" } })), None);
|
||||
}
|
||||
}
|
||||
52
crates/eureka-state/src/bin/huginn/schema.rs
Normal file
52
crates/eureka-state/src/bin/huginn/schema.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! `huginn schema`: the published schema of every document kind and of a
|
||||
//! selection, compiled in, so the schema an agent reads is the schema of the
|
||||
//! binary it runs.
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// CultLib's published shape of a selection, vendored because the selection
|
||||
/// type carries no schema of its own.
|
||||
const SELECTION: &str = include_str!("../../../schemas/cultnet.selection.schema.json");
|
||||
|
||||
macro_rules! kinds {
|
||||
($($name:ident),* $(,)?) => {
|
||||
/// Each kind's file in `schemas/cultnet`, named by `PipelineKind::name`.
|
||||
const KINDS: &[(&str, &str)] = &[$((
|
||||
stringify!($name),
|
||||
include_str!(concat!("../../../../../schemas/cultnet/epiphany.pipeline.", stringify!($name), ".v2.schema.json")),
|
||||
)),*];
|
||||
};
|
||||
}
|
||||
|
||||
kinds!(
|
||||
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship,
|
||||
hand_off, run,
|
||||
);
|
||||
|
||||
/// The schema named, or the names when none is: the exit code is 2 for a name
|
||||
/// that is neither a kind nor `selection`.
|
||||
pub fn text(name: Option<&str>) -> Result<String, Trouble> {
|
||||
match name {
|
||||
None => Ok(KINDS.iter().map(|(kind, _)| *kind).chain(["selection"]).map(|kind| format!("{kind}\n")).collect()),
|
||||
Some("selection") => Ok(SELECTION.to_string()),
|
||||
Some(name) => KINDS
|
||||
.iter()
|
||||
.find(|(kind, _)| *kind == name)
|
||||
.map(|(_, schema)| (*schema).to_string())
|
||||
.ok_or_else(|| Trouble::invalid("no such schema; `huginn schema` lists the names")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use huginn_mind::eureka_pipeline::PipelineKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kind_has_exactly_its_schema() {
|
||||
let names: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
|
||||
let kinds: Vec<&str> = PipelineKind::ALL.iter().map(|kind| kind.name()).collect();
|
||||
assert_eq!(names, kinds);
|
||||
}
|
||||
}
|
||||
79
crates/eureka-state/src/bin/huginn/trouble.rs
Normal file
79
crates/eureka-state/src/bin/huginn/trouble.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//! Why a call produced no answer, and the exit code each reason owns.
|
||||
//!
|
||||
//! Exit codes: 2 the call is wrong (usage, InvalidInput, TooLarge,
|
||||
//! Misconfigured); 3 Unavailable (retry once); 4 no answer and a retry cannot
|
||||
//! help (Rejected, Unencodable, Internal); 5 ReadBackDiffers. Exit 0 and 1 are
|
||||
//! answers (the mind said yes, the mind said no) and are not troubles.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use eureka_state::ClientError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Trouble {
|
||||
Misconfigured(String),
|
||||
InvalidInput(String),
|
||||
TooLarge { bytes: usize, limit: usize },
|
||||
Unavailable(String),
|
||||
Rejected(String),
|
||||
Unencodable(String),
|
||||
Internal(String),
|
||||
ReadBackDiffers(String),
|
||||
}
|
||||
|
||||
impl Trouble {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Misconfigured(_) => "Misconfigured",
|
||||
Self::InvalidInput(_) => "InvalidInput",
|
||||
Self::TooLarge { .. } => "TooLarge",
|
||||
Self::Unavailable(_) => "Unavailable",
|
||||
Self::Rejected(_) => "Rejected",
|
||||
Self::Unencodable(_) => "Unencodable",
|
||||
Self::Internal(_) => "Internal",
|
||||
Self::ReadBackDiffers(_) => "ReadBackDiffers",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit(&self) -> u8 {
|
||||
match self {
|
||||
Self::Misconfigured(_) | Self::InvalidInput(_) | Self::TooLarge { .. } => 2,
|
||||
Self::Unavailable(_) => 3,
|
||||
Self::Rejected(_) | Self::Unencodable(_) | Self::Internal(_) => 4,
|
||||
Self::ReadBackDiffers(_) => 5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidInput(detail.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Trouble {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: ", self.code())?;
|
||||
match self {
|
||||
Self::Misconfigured(detail)
|
||||
| Self::InvalidInput(detail)
|
||||
| Self::Unavailable(detail)
|
||||
| Self::Rejected(detail)
|
||||
| Self::Unencodable(detail)
|
||||
| Self::Internal(detail)
|
||||
| Self::ReadBackDiffers(detail) => f.write_str(detail),
|
||||
Self::TooLarge { bytes, limit } => {
|
||||
write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientError> for Trouble {
|
||||
fn from(error: ClientError) -> Self {
|
||||
match error {
|
||||
ClientError::Unavailable { endpoint, detail } => Self::Unavailable(format!("rudp://{endpoint}: {detail}")),
|
||||
ClientError::Rejected { endpoint, code, detail } => Self::Rejected(format!("rudp://{endpoint}: {code}: {detail}")),
|
||||
ClientError::TooLarge { bytes, limit } => Self::TooLarge { bytes, limit },
|
||||
ClientError::Unencodable { detail } => Self::Unencodable(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,8 @@ use cultnet_rs::{
|
|||
};
|
||||
use huginn_mind::envelope::{OperationFailure, decode_response, encode_request};
|
||||
use huginn_mind::eureka_pipeline::Slug;
|
||||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES};
|
||||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES, PipelineProvenance};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The correlation key of the one request a session carries.
|
||||
const MESSAGE_ID: &str = "eureka-state-call";
|
||||
|
|
@ -108,6 +109,28 @@ impl HuginnClient {
|
|||
&self.instance
|
||||
}
|
||||
|
||||
/// The mind's persona document: `Persona(None)` until the first put.
|
||||
pub fn persona_get(&self) -> Result<HuginnMindResponse, ClientError> {
|
||||
self.call(HuginnMindRequest::PersonaGet { instance: self.instance.clone() })
|
||||
}
|
||||
|
||||
/// Replaces the mind's persona document whole. `expected_updated_at` is
|
||||
/// the stored document's `updatedAt`, none for the first put; a stale one
|
||||
/// comes back as `PersonaPut(Refused(PersonaStale { .. }))`, an answer.
|
||||
pub fn persona_put(
|
||||
&self,
|
||||
provenance: PipelineProvenance,
|
||||
state: Value,
|
||||
expected_updated_at: Option<String>,
|
||||
) -> Result<HuginnMindResponse, ClientError> {
|
||||
self.call(HuginnMindRequest::PersonaPut {
|
||||
instance: self.instance.clone(),
|
||||
provenance,
|
||||
state,
|
||||
expected_updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn call(&self, request: HuginnMindRequest) -> Result<HuginnMindResponse, ClientError> {
|
||||
let deadline = Instant::now() + self.timeout;
|
||||
let unavailable =
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! `eureka-state`: Huginn's mind as four MCP tools over stdio.
|
||||
//! `eureka-state`: Huginn's mind as six MCP tools over stdio.
|
||||
//!
|
||||
//! One tool per wire operation (`whoami`, `admit`, `view`, `query`), each a
|
||||
//! `HuginnClient` call and nothing else. The declared identity is this
|
||||
//! One tool per wire operation (`whoami`, `admit`, `view`, `query`,
|
||||
//! `persona_get`, `persona_put`), each a `HuginnClient` call and nothing else. The declared identity is this
|
||||
//! process's configuration, not a per-call argument: `EUREKA_INSTANCE` names
|
||||
//! the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that holds
|
||||
//! it. The server fills in `instance` and `provenance.tool`, so a caller can
|
||||
|
|
@ -73,6 +73,21 @@ struct AdmitInput {
|
|||
documents: Vec<PipelineDocument>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct PersonaPutInput {
|
||||
/// The faculty the put is attributed to. Attribution, not authority.
|
||||
faculty: Faculty,
|
||||
/// The putting agent's name.
|
||||
agent: Short,
|
||||
/// The putting session, recorded on the receipt.
|
||||
session: Short,
|
||||
/// The whole `gamecult.persona_state.v0` document. It replaces the stored
|
||||
/// one; there is no partial write.
|
||||
state: Value,
|
||||
/// The stored document's `updatedAt`; omit it for the first put.
|
||||
expected_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ViewInput {
|
||||
/// The document's id, as a query or an admission returned it.
|
||||
|
|
@ -166,17 +181,32 @@ impl EurekaState {
|
|||
|
||||
/// One wire call for the configured instance. The request is built from
|
||||
/// that instance, never from anything the caller sent.
|
||||
async fn call(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> Result<HuginnMindResponse, Trouble> {
|
||||
async fn call(
|
||||
&self,
|
||||
request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static,
|
||||
) -> Result<HuginnMindResponse, Trouble> {
|
||||
self.ask(move |client| client.call(request(client.instance().clone()))).await
|
||||
}
|
||||
|
||||
/// One `HuginnClient` call for the configured instance, off the async
|
||||
/// thread.
|
||||
async fn ask(
|
||||
&self,
|
||||
call: impl FnOnce(&HuginnClient) -> Result<HuginnMindResponse, ClientError> + Send + 'static,
|
||||
) -> Result<HuginnMindResponse, Trouble> {
|
||||
let client = self.settings.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))?.clone();
|
||||
let request = request(client.instance().clone());
|
||||
tokio::task::spawn_blocking(move || client.call(request))
|
||||
tokio::task::spawn_blocking(move || call(&client))
|
||||
.await
|
||||
.map_err(|error| Trouble::Internal(error.to_string()))?
|
||||
.map_err(Trouble::Client)
|
||||
}
|
||||
|
||||
async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> CallToolResult {
|
||||
match self.call(request).await {
|
||||
async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static) -> CallToolResult {
|
||||
Self::replied(self.call(request).await)
|
||||
}
|
||||
|
||||
fn replied(result: Result<HuginnMindResponse, Trouble>) -> CallToolResult {
|
||||
match result {
|
||||
Ok(response) => reply(payload(response), false),
|
||||
Err(trouble) => reply(trouble.body(), true),
|
||||
}
|
||||
|
|
@ -191,6 +221,8 @@ fn payload(response: HuginnMindResponse) -> Value {
|
|||
HuginnMindResponse::Admit(outcome) => serde_json::to_value(outcome),
|
||||
HuginnMindResponse::View(view) => serde_json::to_value(view),
|
||||
HuginnMindResponse::Query(page) => serde_json::to_value(page),
|
||||
HuginnMindResponse::Persona(view) => serde_json::to_value(view),
|
||||
HuginnMindResponse::PersonaPut(outcome) => serde_json::to_value(outcome),
|
||||
other => serde_json::to_value(other),
|
||||
};
|
||||
value.expect("wire types serialize")
|
||||
|
|
@ -228,7 +260,7 @@ impl EurekaState {
|
|||
description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one."
|
||||
)]
|
||||
async fn admit(&self, Parameters(input): Parameters<AdmitInput>) -> CallToolResult {
|
||||
self.answer(|instance| {
|
||||
self.answer(move |instance| {
|
||||
HuginnMindRequest::Admit(PipelineAdmissionBatch {
|
||||
instance,
|
||||
provenance: PipelineProvenance {
|
||||
|
|
@ -245,20 +277,42 @@ impl EurekaState {
|
|||
|
||||
#[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")]
|
||||
async fn view(&self, Parameters(input): Parameters<ViewInput>) -> CallToolResult {
|
||||
self.answer(|instance| HuginnMindRequest::View { instance, id: input.id }).await
|
||||
self.answer(move |instance| HuginnMindRequest::View { instance, id: input.id }).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next."
|
||||
)]
|
||||
async fn query(&self, Parameters(input): Parameters<QueryInput>) -> CallToolResult {
|
||||
self.answer(|instance| HuginnMindRequest::Query {
|
||||
self.answer(move |instance| HuginnMindRequest::Query {
|
||||
instance,
|
||||
selection: input.selection,
|
||||
semantic: input.semantic,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "The mind's persona document (gamecult.persona_state.v0) with its updatedAt and the receipt of the put that wrote it. null until the first put."
|
||||
)]
|
||||
async fn persona_get(&self) -> CallToolResult {
|
||||
Self::replied(self.ask(HuginnClient::persona_get).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Replace the mind's persona document whole. The document must pass the published schema, name this instance as personaId, carry provenance.authority canonical, and have an updatedAt later than the stored one; expected_updated_at must be the stored updatedAt (omit it for the first put). A refusal is an answer; a stale put names the stored updatedAt to retry against."
|
||||
)]
|
||||
async fn persona_put(&self, Parameters(input): Parameters<PersonaPutInput>) -> CallToolResult {
|
||||
let provenance = PipelineProvenance {
|
||||
faculty: input.faculty,
|
||||
agent: input.agent,
|
||||
session: input.session,
|
||||
tool: Short(TOOL.into()),
|
||||
};
|
||||
Self::replied(
|
||||
self.ask(move |client| client.persona_put(provenance, input.state, input.expected_updated_at)).await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool error is a typed JSON body. The ones this server writes already
|
||||
|
|
@ -289,7 +343,7 @@ impl ServerHandler for EurekaState {
|
|||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
|
||||
"Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
|
||||
"Huginn's typed mind for this instance: whoami, admit, view, query, persona_get, persona_put. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1047
crates/eureka-state/tests/cli.rs
Normal file
1047
crates/eureka-state/tests/cli.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,9 @@ use huginn_mind::eureka_pipeline::{
|
|||
PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title,
|
||||
VerificationTest,
|
||||
};
|
||||
use huginn_mind::{DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionOutcome};
|
||||
use huginn_mind::{
|
||||
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PersonaPutOutcome, PipelineAdmissionOutcome,
|
||||
};
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
|
@ -195,6 +197,50 @@ fn every_operation_round_trips_against_a_live_daemon() {
|
|||
assert_eq!(view.id, campaign_ref);
|
||||
}
|
||||
|
||||
/// The persona methods against a live daemon over the socket: put then get
|
||||
/// returns the value and the receipt, and a stale put or another mind's name is
|
||||
/// an answer, not an error.
|
||||
#[test]
|
||||
fn client_persona_round_trip_against_a_live_daemon() {
|
||||
const T1: &str = "2026-10-10T10:00:00Z";
|
||||
const T2: &str = "2026-10-10T10:00:01Z";
|
||||
let (root, daemon) = mind(vec![]);
|
||||
let server = serve(root, daemon);
|
||||
let client = client(server.addr);
|
||||
|
||||
assert_eq!(client.persona_get().unwrap(), HuginnMindResponse::Persona(None));
|
||||
let HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id }) =
|
||||
client.persona_put(provenance(), persona(INSTANCE, T1), None).unwrap()
|
||||
else {
|
||||
panic!("the first put commits");
|
||||
};
|
||||
let HuginnMindResponse::Persona(Some(view)) = client.persona_get().unwrap() else { panic!("the persona is stored") };
|
||||
assert_eq!(view.value, persona(INSTANCE, T1));
|
||||
assert_eq!((view.updated_at.as_str(), view.receipt_id.as_str()), (T1, receipt_id.as_str()));
|
||||
let HuginnMindResponse::Whoami(status) = client.call(HuginnMindRequest::Whoami).unwrap() else { panic!("a status") };
|
||||
assert_eq!(status.persona, huginn_mind::wire::PersonaStatus::Stored { updated_at: T1.into() });
|
||||
|
||||
// A stale put is an answer.
|
||||
assert_eq!(
|
||||
client.persona_put(provenance(), persona(INSTANCE, T2), None).unwrap(),
|
||||
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(MindRefusal::PersonaStale { stored: Some(T1.into()) }))
|
||||
);
|
||||
let HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { .. }) =
|
||||
client.persona_put(provenance(), persona(INSTANCE, T2), Some(T1.into())).unwrap()
|
||||
else {
|
||||
panic!("a put that read the stored document commits");
|
||||
};
|
||||
|
||||
// Another mind's name is an answer too.
|
||||
let other = HuginnClient::new(server.addr, slug(OTHER), TIMEOUT);
|
||||
let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() };
|
||||
assert_eq!(other.persona_get().unwrap(), HuginnMindResponse::Refused(foreign.clone()));
|
||||
assert_eq!(
|
||||
other.persona_put(provenance(), persona(INSTANCE, "2026-10-10T10:00:02Z"), Some(T2.into())).unwrap(),
|
||||
HuginnMindResponse::Refused(foreign)
|
||||
);
|
||||
}
|
||||
|
||||
/// A refusal is data. A read that names another mind, a write that declares
|
||||
/// another mind, and a document the mind does not hold each come back as `Ok`.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use huginn_mind::{
|
|||
Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindStore, OwnedRedbMessagePackBackingStore,
|
||||
PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub const INSTANCE: &str = "eureka";
|
||||
|
|
@ -38,19 +39,46 @@ pub fn identity(name: &str) -> PipelineDocument {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn batch(instance: &str, documents: Vec<PipelineDocument>) -> PipelineAdmissionBatch {
|
||||
PipelineAdmissionBatch {
|
||||
instance: slug(instance),
|
||||
provenance: PipelineProvenance {
|
||||
faculty: Faculty::Hands,
|
||||
agent: Short("claude".into()),
|
||||
session: Short("session-1".into()),
|
||||
tool: Short("admit".into()),
|
||||
},
|
||||
documents,
|
||||
pub fn provenance() -> PipelineProvenance {
|
||||
PipelineProvenance {
|
||||
faculty: Faculty::Hands,
|
||||
agent: Short("claude".into()),
|
||||
session: Short("session-1".into()),
|
||||
tool: Short("admit".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch(instance: &str, documents: Vec<PipelineDocument>) -> PipelineAdmissionBatch {
|
||||
PipelineAdmissionBatch { instance: slug(instance), provenance: provenance(), documents }
|
||||
}
|
||||
|
||||
/// A minimal `gamecult.persona_state.v0` document: the published schema's
|
||||
/// required fields and nothing else.
|
||||
pub fn persona(id: &str, updated_at: &str) -> Value {
|
||||
json!({
|
||||
"schemaVersion": "gamecult.persona_state.v0",
|
||||
"provenance": {
|
||||
"sourceSystem": "huginn", "sourceDocumentId": id, "sourceUpdatedAt": updated_at,
|
||||
"exportedAt": updated_at, "authority": "canonical"
|
||||
},
|
||||
"personaId": id,
|
||||
"publicName": "Test Persona",
|
||||
"presentation": { "voiceSummary": "plain" },
|
||||
"activationProfile": {
|
||||
"underlyingOrganization": {}, "stableDispositions": {}, "behavioralDimensions": {},
|
||||
"presentationStrategy": {}, "voiceStyle": {}, "situationalState": {}
|
||||
},
|
||||
"thoughtMemory": { "shortTerm": [], "memories": [], "incubation": [] },
|
||||
"agencyPressure": { "pressures": [] },
|
||||
"candidateActions": { "actions": [] },
|
||||
"affect": {
|
||||
"needs": [], "socialBonds": [], "statusReads": [], "moodDimensions": [],
|
||||
"socialBiases": [], "doctrineStances": []
|
||||
},
|
||||
"updatedAt": updated_at
|
||||
})
|
||||
}
|
||||
|
||||
/// No index: the client's subject is the transport, not the projection.
|
||||
pub struct Inert;
|
||||
|
||||
|
|
|
|||
|
|
@ -148,15 +148,15 @@ fn keys_below_root(schema: &Value, key: &str) -> usize {
|
|||
schema.as_object().unwrap().values().map(|inner| count(inner, key)).sum()
|
||||
}
|
||||
|
||||
/// Four tools, each input schema a self-contained document: every `$ref` is
|
||||
/// Six tools, each input schema a self-contained document: every `$ref` is
|
||||
/// internal and resolves, and the selection's definitions are bundled in.
|
||||
#[test]
|
||||
fn tools_list_offers_four_tools_with_self_contained_schemas() {
|
||||
fn tools_list_offers_six_tools_with_self_contained_schemas() {
|
||||
let mut mcp = Mcp::at(INSTANCE, closed_port());
|
||||
let listed = mcp.rpc("tools/list", json!({}));
|
||||
let tools = listed["result"]["tools"].as_array().expect("tools");
|
||||
let names: BTreeSet<&str> = tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query"]));
|
||||
assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query", "persona_get", "persona_put"]));
|
||||
for tool in tools {
|
||||
let schema = &tool["inputSchema"];
|
||||
let mut found = Vec::new();
|
||||
|
|
@ -248,6 +248,49 @@ fn every_tool_round_trips_against_a_live_daemon() {
|
|||
assert_eq!(counted["status"]["receipts"], json!(3));
|
||||
}
|
||||
|
||||
/// The persona tools against a live daemon: the document, the stale answer,
|
||||
/// and `whoami` showing the stored `updatedAt`. The server fills in the
|
||||
/// instance and the tool, so the asker names neither.
|
||||
#[test]
|
||||
fn the_persona_tools_round_trip_against_a_live_daemon() {
|
||||
const T1: &str = "2026-10-10T10:00:00Z";
|
||||
const T2: &str = "2026-10-10T10:00:01Z";
|
||||
let (root, daemon) = mind(vec![]);
|
||||
let server = serve(root, daemon);
|
||||
let mut mcp = Mcp::at(INSTANCE, server.addr);
|
||||
let put = |state: Value, expected: Option<&str>| {
|
||||
let mut arguments = json!({ "faculty": "SelfFaculty", "agent": "claude", "session": "session-7", "state": state });
|
||||
if let Some(expected) = expected {
|
||||
arguments["expected_updated_at"] = json!(expected);
|
||||
}
|
||||
arguments
|
||||
};
|
||||
|
||||
let (error, nothing) = mcp.call("persona_get", json!({}));
|
||||
assert!(!error);
|
||||
assert_eq!(nothing, Value::Null);
|
||||
let (_, whoami) = mcp.call("whoami", json!({}));
|
||||
assert_eq!(whoami["status"]["persona"], json!("Absent"));
|
||||
|
||||
let (error, committed) = mcp.call("persona_put", put(persona(INSTANCE, T1), None));
|
||||
assert!(!error);
|
||||
let receipt = committed["Committed"]["receipt_id"].as_str().expect("committed").to_string();
|
||||
|
||||
let (error, view) = mcp.call("persona_get", json!({}));
|
||||
assert!(!error);
|
||||
assert_eq!(view["value"], persona(INSTANCE, T1));
|
||||
assert_eq!((view["updated_at"].as_str(), view["receipt_id"].as_str()), (Some(T1), Some(receipt.as_str())));
|
||||
let (_, whoami) = mcp.call("whoami", json!({}));
|
||||
assert_eq!(whoami["status"]["persona"], json!({ "Stored": { "updated_at": T1 } }));
|
||||
|
||||
let (error, stale) = mcp.call("persona_put", put(persona(INSTANCE, T2), None));
|
||||
assert!(!error, "a stale put is an answer");
|
||||
assert_eq!(stale["Refused"]["PersonaStale"]["stored"], json!(T1));
|
||||
|
||||
let (_, page) = mcp.call("query", json!({ "selection": { "schemas": ["epiphany.pipeline.instance.v2"] } }));
|
||||
assert_eq!(page["matched"], json!(1), "the persona is not a pipeline document");
|
||||
}
|
||||
|
||||
/// Declared identity is configuration, not a per-call argument. Input that
|
||||
/// names another instance, or another tool, changes neither: the batch is
|
||||
/// admitted for the configured instance, and the receipt says `eureka-state`.
|
||||
|
|
@ -367,6 +410,8 @@ fn bad_configuration_is_served_and_reported() {
|
|||
("view", json!({ "id": absent() })),
|
||||
("query", json!({ "selection": {} })),
|
||||
("admit", faculty_args(vec![])),
|
||||
("persona_get", json!({})),
|
||||
("persona_put", json!({ "faculty": "Hands", "agent": "claude", "session": "s", "state": {} })),
|
||||
] {
|
||||
let (error, refusal) = mcp.call(tool, arguments);
|
||||
assert!(error, "{tool}: {expected}");
|
||||
|
|
|
|||
|
|
@ -153,6 +153,13 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
|
|||
Ok(view) => HuginnMindResponse::View(view.map(Box::new)),
|
||||
Err(refusal) => HuginnMindResponse::Refused(refusal),
|
||||
},
|
||||
HuginnMindRequest::PersonaGet { .. } => match self.mind.persona() {
|
||||
Ok(view) => HuginnMindResponse::Persona(view),
|
||||
Err(refusal) => HuginnMindResponse::Refused(refusal),
|
||||
},
|
||||
HuginnMindRequest::PersonaPut { instance, provenance, state, expected_updated_at } => {
|
||||
HuginnMindResponse::PersonaPut(self.mind.put_persona(&instance, state, expected_updated_at, provenance, now))
|
||||
}
|
||||
HuginnMindRequest::Query { selection, semantic: None, .. } => match self.mind.query(&selection) {
|
||||
Ok(page) => HuginnMindResponse::Query(page),
|
||||
Err(refusal) => HuginnMindResponse::Refused(refusal),
|
||||
|
|
@ -583,6 +590,7 @@ pub(crate) mod tests {
|
|||
documents: 1,
|
||||
receipts: 1,
|
||||
index: IndexStatus::Current,
|
||||
persona: huginn_mind::wire::PersonaStatus::Absent,
|
||||
}
|
||||
);
|
||||
assert_eq!(daemon.runtime_id(), "huginn-yggdrasil");
|
||||
|
|
@ -674,6 +682,31 @@ pub(crate) mod tests {
|
|||
assert_eq!(status(&mut daemon).documents, 1, "nothing landed");
|
||||
}
|
||||
|
||||
/// The persona operations name an instance like every other read: another
|
||||
/// mind's name is refused before the mind is asked, and a mind with no
|
||||
/// persona answers `Persona(None)`.
|
||||
#[test]
|
||||
fn the_persona_operations_name_this_mind_and_an_empty_mind_has_none() {
|
||||
let (_root, mut daemon) = seeded();
|
||||
assert_eq!(
|
||||
daemon.handle(HuginnMindRequest::PersonaGet { instance: slug(INSTANCE) }, now()).answered(),
|
||||
HuginnMindResponse::Persona(None)
|
||||
);
|
||||
let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() };
|
||||
assert_eq!(
|
||||
daemon.handle(HuginnMindRequest::PersonaGet { instance: slug(OTHER) }, now()).answered(),
|
||||
HuginnMindResponse::Refused(foreign.clone())
|
||||
);
|
||||
let put = HuginnMindRequest::PersonaPut {
|
||||
instance: slug(OTHER),
|
||||
provenance: provenance(),
|
||||
state: serde_json::Value::Null,
|
||||
expected_updated_at: None,
|
||||
};
|
||||
assert_eq!(daemon.handle(put, now()).answered(), HuginnMindResponse::Refused(foreign));
|
||||
assert_eq!(status(&mut daemon).persona, huginn_mind::wire::PersonaStatus::Absent);
|
||||
}
|
||||
|
||||
/// A declared instance outside `Slug`'s grammar is refused by its own
|
||||
/// name, `InvalidFormat`, on the read path, before the identity
|
||||
/// comparison ever runs: it is not merely a foreign mind, it names no
|
||||
|
|
|
|||
|
|
@ -21,13 +21,15 @@ chrono = "0.4.44"
|
|||
cultcache-rs.workspace = true
|
||||
cultnet-rs.workspace = true
|
||||
eureka-pipeline = { path = "../eureka-pipeline" }
|
||||
jsonschema = { version = "0.58.6", default-features = false }
|
||||
rmp-serde = "1"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_path_to_error = "0.1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
# `test-support` exposes `Mind::store_path_for` and `Mind::open_with`, a
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@ use eureka_pipeline::{
|
|||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations};
|
||||
use crate::mind::{HuginnMindEpoch, Mind, unavailable};
|
||||
use crate::persona::{self, HuginnPersonaEntry, PersonaPutOutcome};
|
||||
use crate::receipt::{self, CommitOutcome, PipelineProvenance};
|
||||
use crate::refusal::{MindRefusal, RunId};
|
||||
use crate::store::MindStore;
|
||||
|
|
@ -178,6 +180,86 @@ impl<S: MindStore> Mind<S> {
|
|||
CommitOutcome::Conflict(identities) => Ok(PipelineAdmissionOutcome::Conflict { identities }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the mind's persona document whole, or refuses. One door for
|
||||
/// the one document a mind holds, outside every pipeline batch: the same
|
||||
/// instance and provenance checks as `admit`, then the persona's five
|
||||
/// rules in order (schema-valid, `personaId` is this mind, canonical
|
||||
/// provenance, `expected_updated_at` is the stored `updatedAt`, the new
|
||||
/// `updatedAt` is later), then the same commit primitive. The stored
|
||||
/// document is the commit's strong read, so the swap fails if it changed.
|
||||
pub fn put_persona(
|
||||
&mut self,
|
||||
instance: &Slug,
|
||||
state: Value,
|
||||
expected_updated_at: Option<String>,
|
||||
provenance: PipelineProvenance,
|
||||
now: DateTime<Utc>,
|
||||
) -> PersonaPutOutcome {
|
||||
match self.persona_steps(instance, state, expected_updated_at, provenance, now) {
|
||||
Ok(receipt_id) => PersonaPutOutcome::Committed { receipt_id },
|
||||
Err(refusal) => PersonaPutOutcome::Refused(refusal),
|
||||
}
|
||||
}
|
||||
|
||||
fn persona_steps(
|
||||
&mut self,
|
||||
instance: &Slug,
|
||||
state: Value,
|
||||
expected_updated_at: Option<String>,
|
||||
provenance: PipelineProvenance,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<String, MindRefusal> {
|
||||
self.require_instance(instance)?;
|
||||
refuse_provenance(&provenance)?;
|
||||
// The persona rides a mind that has its identity (A6's analogue).
|
||||
if self.is_empty() {
|
||||
return Err(MindRefusal::MissingIdentity);
|
||||
}
|
||||
// P1
|
||||
persona::validate_shape(&state)?;
|
||||
// P2
|
||||
let mind = self.instance().clone();
|
||||
if state.get("personaId").and_then(Value::as_str) != Some(mind.0.as_str()) {
|
||||
return Err(MindRefusal::PersonaForeign { instance: mind.0 });
|
||||
}
|
||||
// P3
|
||||
if state.pointer("/provenance/authority").and_then(Value::as_str) != Some("canonical") {
|
||||
return Err(MindRefusal::PersonaNotCanonical);
|
||||
}
|
||||
// P4
|
||||
let stored = self.stored_updated_at()?;
|
||||
if stored != expected_updated_at {
|
||||
return Err(MindRefusal::PersonaStale { stored });
|
||||
}
|
||||
// P5
|
||||
if let Some(stored) = &stored {
|
||||
let instant = |stamp: &str| {
|
||||
DateTime::parse_from_rfc3339(stamp).map_err(|_| MindRefusal::PersonaInvalid {
|
||||
path: "/updatedAt".into(),
|
||||
message: "is not an RFC 3339 date-time".into(),
|
||||
})
|
||||
};
|
||||
let offered = state.get("updatedAt").and_then(Value::as_str).map(instant).transpose()?;
|
||||
if offered <= Some(instant(stored)?) {
|
||||
return Err(MindRefusal::PersonaInvalid {
|
||||
path: "/updatedAt".into(),
|
||||
message: "must be later than the stored updatedAt".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let (envelope, _) = self
|
||||
.cache()
|
||||
.prepare_entry_named(mind.0.clone(), &HuginnPersonaEntry { value: state })
|
||||
.map_err(unavailable)?;
|
||||
let strong_reads = self.persona_envelope().cloned().into_iter().collect::<Vec<_>>();
|
||||
let writes = vec![envelope];
|
||||
let candidate = receipt::candidate(&mind, provenance, &strong_reads, &writes, receipt::head(self)? + 1, now)?;
|
||||
match receipt::commit(self, candidate, strong_reads, writes)? {
|
||||
CommitOutcome::Committed(receipt) => Ok(receipt.receipt_id),
|
||||
CommitOutcome::Conflict(_) => Err(MindRefusal::PersonaStale { stored: self.stored_updated_at()? }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound `eureka_pipeline::Short` declares, in UTF-8 bytes. The leaf
|
||||
|
|
|
|||
|
|
@ -70,28 +70,50 @@ pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindReq
|
|||
));
|
||||
};
|
||||
if service_id != MIND_SERVICE_ID {
|
||||
return Err(OperationFailure::new("wrong-service", format!("{service_id} is not {MIND_SERVICE_ID}")));
|
||||
return Err(OperationFailure::new("wrong-service", format!("the service is not {MIND_SERVICE_ID}")));
|
||||
}
|
||||
if payload_schema != MIND_REQUEST_SCHEMA {
|
||||
return Err(OperationFailure::new(
|
||||
"wrong-payload-schema",
|
||||
format!("{payload_schema} is not {MIND_REQUEST_SCHEMA}"),
|
||||
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
|
||||
));
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|error| OperationFailure::new("payload-not-base64", error.to_string()))?;
|
||||
let request: HuginnMindRequest = rmp_serde::from_slice(&bytes)
|
||||
.map_err(|error| OperationFailure::new("payload-not-a-request", error.to_string()))?;
|
||||
.map_err(|_| OperationFailure::new("payload-not-base64", "the payload is not standard base64"))?;
|
||||
let request = read_request(&bytes)?;
|
||||
if operation != request.operation() {
|
||||
return Err(OperationFailure::new(
|
||||
"operation-mismatch",
|
||||
format!("the envelope says {operation} and the payload is {}", request.operation()),
|
||||
format!("the envelope's operation is not the payload's {}", request.operation()),
|
||||
));
|
||||
}
|
||||
Ok((message_id.clone(), request))
|
||||
}
|
||||
|
||||
/// The payload as a request, 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> {
|
||||
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| {
|
||||
let class = match error.inner() {
|
||||
Decode::InvalidMarkerRead(_) | Decode::InvalidDataRead(_) => "truncated",
|
||||
Decode::TypeMismatch(_) => "wrong-type",
|
||||
Decode::OutOfRange => "out-of-range",
|
||||
Decode::LengthMismatch(_) => "wrong-length",
|
||||
Decode::Uncategorized(_) | Decode::Syntax(_) => "invalid-value",
|
||||
Decode::Utf8Error(_) => "invalid-text",
|
||||
Decode::DepthLimitExceeded => "too-deep",
|
||||
};
|
||||
let path = error.path().to_string();
|
||||
let at = if path == "." { "the payload".to_string() } else { format!("field {path}") };
|
||||
OperationFailure::new("payload-not-a-request", format!("{at}: {class}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// One answer, whose status the response derives.
|
||||
pub fn encode_response(
|
||||
message_id: &str,
|
||||
|
|
@ -184,6 +206,7 @@ mod tests {
|
|||
documents: 0,
|
||||
receipts: 0,
|
||||
index: crate::wire::IndexStatus::Current,
|
||||
persona: crate::wire::PersonaStatus::Absent,
|
||||
}),
|
||||
"accepted",
|
||||
),
|
||||
|
|
@ -209,6 +232,82 @@ mod tests {
|
|||
assert_eq!(decode_response(&message).unwrap(), ("m-2".into(), Err(failure)));
|
||||
}
|
||||
|
||||
fn request_message(service_id: &str, operation: &str, schema: &str, payload: String) -> CultNetMessage {
|
||||
CultNetMessage::OperationRequest {
|
||||
message_id: "m-9".into(),
|
||||
service_id: service_id.into(),
|
||||
operation: operation.into(),
|
||||
payload_schema: schema.into(),
|
||||
payload_encoding: "messagepack-base64".into(),
|
||||
payload,
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload of `request` with the value at `path` replaced, encoded the
|
||||
/// way a client does.
|
||||
fn payload_with(request: &HuginnMindRequest, path: &[&str], bad: serde_json::Value) -> String {
|
||||
let mut value = serde_json::to_value(request).unwrap();
|
||||
let mut at = &mut value;
|
||||
for key in path {
|
||||
at = at.get_mut(*key).unwrap_or_else(|| panic!("no {key} in the request"));
|
||||
}
|
||||
*at = bad;
|
||||
STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap())
|
||||
}
|
||||
|
||||
/// A refusal of the decoder names the field and the class of error and
|
||||
/// nothing the caller sent: not an unknown variant, not an integer, not
|
||||
/// an envelope field, not a byte of bad base64. Over the persona wire and
|
||||
/// over the pipeline's, because the decoder is one.
|
||||
#[test]
|
||||
fn the_decoder_never_echoes_what_the_caller_sent() {
|
||||
const CANARY: &str = "CANARY-5c0de1-do-not-echo";
|
||||
const NUMBER: i64 = 90817263;
|
||||
let put = HuginnMindRequest::PersonaPut {
|
||||
instance: slug(INSTANCE),
|
||||
provenance: provenance(Faculty::Hands),
|
||||
state: serde_json::json!({}),
|
||||
expected_updated_at: None,
|
||||
};
|
||||
let admit = HuginnMindRequest::Admit(PipelineAdmissionBatch {
|
||||
instance: slug(INSTANCE),
|
||||
provenance: provenance(Faculty::Hands),
|
||||
documents: vec![instance(INSTANCE)],
|
||||
});
|
||||
let cases = [
|
||||
(&put, vec!["PersonaPut", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
|
||||
(&put, vec!["PersonaPut", "provenance", "agent"], serde_json::json!(NUMBER), "provenance.agent"),
|
||||
(&put, vec!["PersonaPut", "expected_updated_at"], serde_json::json!(NUMBER), "expected_updated_at"),
|
||||
(&put, vec!["PersonaPut", "instance"], serde_json::json!(NUMBER), "instance"),
|
||||
(&admit, vec!["Admit", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
|
||||
(&admit, vec!["Admit", "instance"], serde_json::json!(NUMBER), "instance"),
|
||||
];
|
||||
for (request, path, bad, field) in cases {
|
||||
let message = request_message(MIND_SERVICE_ID, request.operation(), MIND_REQUEST_SCHEMA, payload_with(request, &path, bad));
|
||||
let failure = decode_request(&message).unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request");
|
||||
assert!(failure.message.contains(field), "{field} is named: {}", failure.message);
|
||||
assert!(failure.message.ends_with(": invalid-value"), "the class is named: {}", failure.message);
|
||||
for echoed in [CANARY.to_string(), NUMBER.to_string()] {
|
||||
assert!(!failure.message.contains(&echoed), "{echoed} came back: {}", failure.message);
|
||||
}
|
||||
}
|
||||
|
||||
let good = payload_with(&put, &["PersonaPut", "expected_updated_at"], serde_json::Value::Null);
|
||||
let envelope_cases = [
|
||||
request_message(CANARY, "persona_put", MIND_REQUEST_SCHEMA, good.clone()),
|
||||
request_message(MIND_SERVICE_ID, "persona_put", CANARY, good.clone()),
|
||||
request_message(MIND_SERVICE_ID, CANARY, MIND_REQUEST_SCHEMA, good),
|
||||
request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, format!("{CANARY}!!")),
|
||||
];
|
||||
for message in envelope_cases {
|
||||
let failure = decode_request(&message).unwrap_err();
|
||||
assert!(!failure.message.contains(CANARY), "{} came back: {}", failure.code, failure.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// The round trip the client and the daemon share, and the one code that
|
||||
/// `serve::answer` cannot reach because it answers a foreign message with
|
||||
/// `Error` instead.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ mod docs;
|
|||
pub mod envelope;
|
||||
pub mod index;
|
||||
pub mod mind;
|
||||
pub mod persona;
|
||||
mod rows;
|
||||
pub mod query;
|
||||
pub mod receipt;
|
||||
|
|
@ -44,6 +45,7 @@ pub use docs::CitationRole;
|
|||
pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text};
|
||||
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
|
||||
pub use mind::{HuginnMindEpoch, Mind};
|
||||
pub use persona::{PersonaPutOutcome, PersonaStateView};
|
||||
pub use query::{
|
||||
AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems,
|
||||
PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery,
|
||||
|
|
@ -53,7 +55,7 @@ pub use refusal::{MindRefusal, RunId};
|
|||
pub use store::{MindStore, OwnedRedbMessagePackBackingStore};
|
||||
pub use wire::{
|
||||
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON,
|
||||
MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus,
|
||||
MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus, PersonaStatus,
|
||||
};
|
||||
|
||||
/// The leaf, whole, through the one crate that pins its rev: the daemon and
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessage
|
|||
use cultnet_rs::CursorKey;
|
||||
use eureka_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types};
|
||||
|
||||
use crate::persona::HuginnPersonaEntry;
|
||||
use crate::receipt::HuginnCommitReceipt;
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::store::MindStore;
|
||||
|
|
@ -71,20 +72,22 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu
|
|||
})
|
||||
}
|
||||
|
||||
/// A cache that knows the sixteen types a mind's store may hold: the leaf's
|
||||
/// fourteen through its registrar, the epoch record and the commit receipt.
|
||||
/// It is the cache every envelope is prepared against.
|
||||
/// A cache that knows the seventeen types a mind's store may hold: the leaf's
|
||||
/// fourteen through its registrar, the epoch record, the commit receipt and
|
||||
/// the persona entry. It is the cache every envelope is prepared against.
|
||||
pub(crate) fn schema_cache() -> Result<CultCache, MindRefusal> {
|
||||
let mut cache = CultCache::new();
|
||||
register_pipeline_document_types(&mut cache).map_err(unavailable)?;
|
||||
cache.register_entry_type::<HuginnMindEpoch>().map_err(unavailable)?;
|
||||
cache.register_entry_type::<HuginnCommitReceipt>().map_err(unavailable)?;
|
||||
cache.register_entry_type::<HuginnPersonaEntry>().map_err(unavailable)?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn is_known_type(type_id: &str) -> bool {
|
||||
type_id == HuginnMindEpoch::TYPE
|
||||
|| type_id == HuginnCommitReceipt::TYPE
|
||||
|| type_id == HuginnPersonaEntry::TYPE
|
||||
|| PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id)
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +171,7 @@ impl<S: MindStore> Mind<S> {
|
|||
|
||||
/// Fail-closed, in this order, nothing attached until every step passes:
|
||||
/// pull the raw envelopes; if anything is stored, exactly one epoch
|
||||
/// record at the current epoch; every type is one of the sixteen; and
|
||||
/// record at the current epoch; every type is one of the seventeen; and
|
||||
/// exactly one `instance` document naming the declared instance; then
|
||||
/// register, attach and pull. The epoch gate runs first so a store
|
||||
/// written at a foreign epoch is refused as `ForeignEpoch`, never as
|
||||
|
|
@ -276,11 +279,15 @@ impl<S: MindStore> Mind<S> {
|
|||
}
|
||||
|
||||
/// Step 3: a runtime store, a Mind store or any other file passed by mistake
|
||||
/// dies on its first foreign type.
|
||||
/// dies on its first foreign type, and a second persona envelope is no mind's:
|
||||
/// a mind holds one.
|
||||
fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> {
|
||||
if let Some(foreign) = raw.iter().find(|envelope| !is_known_type(&envelope.r#type)) {
|
||||
return Err(MindRefusal::ForeignStore { r#type: foreign.r#type.clone() });
|
||||
}
|
||||
if raw.iter().filter(|envelope| envelope.r#type == HuginnPersonaEntry::TYPE).count() > 1 {
|
||||
return Err(MindRefusal::ForeignStore { r#type: HuginnPersonaEntry::TYPE.into() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
585
crates/huginn-mind/src/persona.rs
Normal file
585
crates/huginn-mind/src/persona.rs
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
//! The persona document: one `gamecult.persona_state.v0` per mind, stored
|
||||
//! opaque and replaced whole.
|
||||
//!
|
||||
//! The shape is the published schema's, `schemas/cultnet/`, read here with
|
||||
//! `include_bytes!`; the mind holds no Rust type for it, only the value that
|
||||
//! passed the schema. `put_persona` (admission.rs) owns whether a document may
|
||||
//! enter; this module owns what it is checked against and how it is stored and
|
||||
//! read back. Nothing here echoes a caller's value: a refusal names a schema
|
||||
//! location and a keyword, never the text that failed.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cultcache_rs::{CultCacheEnvelope, DatabaseEntry};
|
||||
use jsonschema::paths::LocationSegment;
|
||||
use jsonschema::error::ValidationErrorKind;
|
||||
use jsonschema::Validator;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::mind::Mind;
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::store::MindStore;
|
||||
|
||||
const PERSONA_SCHEMA: &[u8] = include_bytes!("../../../schemas/cultnet/gamecult.persona_state.v0.schema.json");
|
||||
|
||||
/// The stored envelope's payload: the document, one slot, keyed by the mind's
|
||||
/// instance. The cache needs a registered entry type to hold the envelope; the
|
||||
/// document inside stays a value.
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.persona_state.v0", schema = "HuginnPersonaEntry")]
|
||||
pub struct HuginnPersonaEntry {
|
||||
#[cultcache(key = 0)]
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
/// What `PersonaGet` answers: the stored document, its `updatedAt`, and the
|
||||
/// receipt of the put that wrote it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PersonaStateView {
|
||||
pub value: Value,
|
||||
pub updated_at: String,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
/// How a put ended. A refusal is an answer, as for `admit`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum PersonaPutOutcome {
|
||||
Committed { receipt_id: String },
|
||||
Refused(MindRefusal),
|
||||
}
|
||||
|
||||
struct Compiled {
|
||||
validator: Validator,
|
||||
/// Every property name the schema declares. A path segment outside this
|
||||
/// set is a caller's key (`traitMap` keys are free), so it is withheld.
|
||||
declared: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn compiled() -> Result<&'static Compiled, MindRefusal> {
|
||||
static COMPILED: OnceLock<Result<Compiled, String>> = OnceLock::new();
|
||||
COMPILED
|
||||
.get_or_init(|| {
|
||||
let schema: Value = serde_json::from_slice(PERSONA_SCHEMA)
|
||||
.map_err(|error| format!("the published persona schema does not parse: {error}"))?;
|
||||
let validator = jsonschema::draft7::new(&schema)
|
||||
.map_err(|error| format!("the published persona schema does not compile: {error}"))?;
|
||||
let mut declared = BTreeSet::new();
|
||||
declared_names(&schema, &mut declared);
|
||||
Ok(Compiled { validator, declared })
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|detail| MindRefusal::Unavailable { detail: detail.clone() })
|
||||
}
|
||||
|
||||
fn declared_names(node: &Value, names: &mut BTreeSet<String>) {
|
||||
match node {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::Object(properties)) = map.get("properties") {
|
||||
names.extend(properties.keys().cloned());
|
||||
}
|
||||
map.values().for_each(|child| declared_names(child, names));
|
||||
}
|
||||
Value::Array(items) => items.iter().for_each(|child| declared_names(child, names)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 1: the document is valid against the published schema. The first
|
||||
/// failure is the refusal; it names where and which keyword, never the value.
|
||||
pub(crate) fn validate_shape(state: &Value) -> Result<(), MindRefusal> {
|
||||
let compiled = compiled()?;
|
||||
let Err(error) = compiled.validator.validate(state) else { return Ok(()) };
|
||||
let mut path = error
|
||||
.instance_path()
|
||||
.segments()
|
||||
.map(|segment| match segment {
|
||||
LocationSegment::Index(index) => index.to_string(),
|
||||
LocationSegment::Property(name) if compiled.declared.contains(name.as_ref()) => name.into_owned(),
|
||||
LocationSegment::Property(_) => "*".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match error.kind() {
|
||||
ValidationErrorKind::Required { property } => path.extend(property.as_str().map(str::to_string)),
|
||||
ValidationErrorKind::AdditionalProperties { .. } => path.push("*".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
Err(MindRefusal::PersonaInvalid {
|
||||
path: format!("/{}", path.join("/")),
|
||||
message: format!("fails the published schema's {} rule", error.kind().keyword()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A stored persona envelope's document.
|
||||
pub(crate) fn decode(envelope: &CultCacheEnvelope) -> Result<Value, MindRefusal> {
|
||||
rmp_serde::from_slice::<HuginnPersonaEntry>(&envelope.payload)
|
||||
.map(|entry| entry.value)
|
||||
.map_err(|error| MindRefusal::Unavailable { detail: format!("the stored persona does not decode: {error}") })
|
||||
}
|
||||
|
||||
/// A document's `updatedAt`; the schema makes it a required string.
|
||||
pub(crate) fn updated_at(value: &Value) -> Option<&str> {
|
||||
value.get("updatedAt").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
impl<S: MindStore> Mind<S> {
|
||||
/// This mind's persona envelope, if one is stored.
|
||||
pub(crate) fn persona_envelope(&self) -> Option<&CultCacheEnvelope> {
|
||||
self.raw_envelope(HuginnPersonaEntry::TYPE, &self.instance().0)
|
||||
}
|
||||
|
||||
/// The stored `updatedAt`; none when no persona is stored.
|
||||
pub(crate) fn stored_updated_at(&self) -> Result<Option<String>, MindRefusal> {
|
||||
let Some(envelope) = self.persona_envelope() else { return Ok(None) };
|
||||
let value = decode(envelope)?;
|
||||
updated_at(&value)
|
||||
.map(|stamp| Some(stamp.to_string()))
|
||||
.ok_or_else(|| MindRefusal::Unavailable { detail: "the stored persona has no updatedAt".into() })
|
||||
}
|
||||
|
||||
/// `PersonaGet`: the document, its `updatedAt` and the receipt of the put
|
||||
/// that wrote exactly these bytes.
|
||||
pub fn persona(&self) -> Result<Option<PersonaStateView>, MindRefusal> {
|
||||
let Some(envelope) = self.persona_envelope() else { return Ok(None) };
|
||||
let value = decode(envelope)?;
|
||||
let integrity = |detail: &str| MindRefusal::Unavailable { detail: detail.into() };
|
||||
let updated_at = updated_at(&value).ok_or_else(|| integrity("the stored persona has no updatedAt"))?.to_string();
|
||||
let identity = (envelope.r#type.as_str(), envelope.key.as_str());
|
||||
let receipt_id = self
|
||||
.receipts()?
|
||||
.into_iter()
|
||||
.filter(|receipt| receipt.writes.iter().any(|write| write.identity() == identity))
|
||||
.max_by_key(|receipt| receipt.ordinal)
|
||||
.ok_or_else(|| integrity("the stored persona has no receipt"))?
|
||||
.receipt_id;
|
||||
Ok(Some(PersonaStateView { value, updated_at, receipt_id }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fixtures::{INSTANCE, OTHER_INSTANCE, epoch, instance, opened, prepare, provenance, seed, slug};
|
||||
use crate::receipt::Faculty;
|
||||
use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand};
|
||||
use eureka_pipeline::PipelineKind;
|
||||
use serde_json::json;
|
||||
|
||||
const T1: &str = "2026-10-10T10:00:00Z";
|
||||
const T2: &str = "2026-10-10T10:00:01Z";
|
||||
const T3: &str = "2026-10-10T10:00:02Z";
|
||||
const CANARY: &str = "CANARY-7f3a91-do-not-echo";
|
||||
const NOT_LATER: &str = "must be later than the stored updatedAt";
|
||||
|
||||
/// A minimal document the published schema accepts. The test that follows
|
||||
/// holds it to the schema file's own required list.
|
||||
fn persona(id: &str, updated_at: &str) -> Value {
|
||||
let traits = json!({
|
||||
"underlyingOrganization": {}, "stableDispositions": {}, "behavioralDimensions": {},
|
||||
"presentationStrategy": {}, "voiceStyle": {}, "situationalState": {}
|
||||
});
|
||||
json!({
|
||||
"schemaVersion": "gamecult.persona_state.v0",
|
||||
"provenance": {
|
||||
"sourceSystem": "huginn", "sourceDocumentId": id, "sourceUpdatedAt": updated_at,
|
||||
"exportedAt": updated_at, "authority": "canonical"
|
||||
},
|
||||
"personaId": id,
|
||||
"publicName": "Test Persona",
|
||||
"presentation": { "voiceSummary": "plain" },
|
||||
"activationProfile": traits,
|
||||
"thoughtMemory": { "shortTerm": [], "memories": [], "incubation": [] },
|
||||
"agencyPressure": { "pressures": [] },
|
||||
"candidateActions": { "actions": [] },
|
||||
"affect": {
|
||||
"needs": [], "socialBonds": [], "statusReads": [], "moodDimensions": [],
|
||||
"socialBiases": [], "doctrineStances": []
|
||||
},
|
||||
"updatedAt": updated_at
|
||||
})
|
||||
}
|
||||
|
||||
fn schema_file() -> Value {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../schemas/cultnet/gamecult.persona_state.v0.schema.json");
|
||||
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn put<S: MindStore>(mind: &mut Mind<S>, state: Value, expected: Option<&str>) -> PersonaPutOutcome {
|
||||
let instance = mind.instance().clone();
|
||||
mind.put_persona(&instance, state, expected.map(str::to_string), provenance(Faculty::Hands), crate::fixtures::now())
|
||||
}
|
||||
|
||||
fn committed(outcome: PersonaPutOutcome) -> String {
|
||||
match outcome {
|
||||
PersonaPutOutcome::Committed { receipt_id } => receipt_id,
|
||||
other => panic!("expected a commit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(outcome: PersonaPutOutcome) -> MindRefusal {
|
||||
match outcome {
|
||||
PersonaPutOutcome::Refused(refusal) => refusal,
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(path: &str, rule: &str) -> MindRefusal {
|
||||
MindRefusal::PersonaInvalid { path: path.into(), message: format!("fails the published schema's {rule} rule") }
|
||||
}
|
||||
|
||||
fn not_later() -> MindRefusal {
|
||||
MindRefusal::PersonaInvalid { path: "/updatedAt".into(), message: NOT_LATER.into() }
|
||||
}
|
||||
|
||||
fn stale(stored: Option<&str>) -> MindRefusal {
|
||||
MindRefusal::PersonaStale { stored: stored.map(str::to_string) }
|
||||
}
|
||||
|
||||
fn seeded_over(store: &MemoryStore) -> Mind<MemoryStore> {
|
||||
let mut mind = opened(store.clone(), INSTANCE);
|
||||
seed(&mut mind);
|
||||
mind
|
||||
}
|
||||
|
||||
fn persona_in_status<S: MindStore>(mind: &Mind<S>) -> crate::wire::PersonaStatus {
|
||||
mind.status(crate::wire::IndexStatus::Current).persona
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_is_valid_against_the_published_file_and_covers_its_required_fields() {
|
||||
let schema = schema_file();
|
||||
let validator = jsonschema::draft7::new(&schema).unwrap();
|
||||
let document = persona(INSTANCE, T1);
|
||||
assert!(validator.is_valid(&document));
|
||||
for name in schema["required"].as_array().unwrap() {
|
||||
assert!(document.get(name.as_str().unwrap()).is_some(), "the fixture lacks {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_names_are_every_property_name_at_any_depth() {
|
||||
let schema = json!({
|
||||
"properties": { "a": { "properties": { "b": {} } } },
|
||||
"allOf": [{ "properties": { "c": {} } }],
|
||||
"definitions": { "d": { "properties": { "e": {} } } },
|
||||
"required": ["not-a-property"]
|
||||
});
|
||||
let mut names = BTreeSet::new();
|
||||
declared_names(&schema, &mut names);
|
||||
assert_eq!(names, BTreeSet::from(["a", "b", "c", "e"].map(String::from)));
|
||||
}
|
||||
|
||||
/// `identity-write-all-visible`: a put may change any field, the identity
|
||||
/// fields included, and a change the next second is no less admissible.
|
||||
#[test]
|
||||
fn a_put_may_rewrite_identity_fields_and_every_change_lands() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
committed(put(&mut mind, persona(INSTANCE, T1), None));
|
||||
|
||||
let mut renamed = persona(INSTANCE, T2);
|
||||
renamed["publicName"] = json!("A New Name");
|
||||
renamed["publicDescription"] = json!("who she says she is");
|
||||
renamed["presentation"] = json!({ "voiceSummary": "warmer", "pronouns": "she/her" });
|
||||
renamed["values"] = json!([{ "id": "v1", "label": "candor", "priority": 0.9 }]);
|
||||
committed(put(&mut mind, renamed.clone(), Some(T1)));
|
||||
assert_eq!(mind.persona().unwrap().unwrap().value, renamed);
|
||||
|
||||
let mut again = persona(INSTANCE, T3);
|
||||
again["publicName"] = json!("Another Name");
|
||||
committed(put(&mut mind, again.clone(), Some(T2)));
|
||||
assert_eq!(mind.persona().unwrap().unwrap().value, again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_names_the_receipt_of_the_latest_put() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
let mut previous: Option<String> = None;
|
||||
for second in 0..5 {
|
||||
let stamp = format!("2026-10-10T10:00:0{second}Z");
|
||||
let receipt = committed(put(&mut mind, persona(INSTANCE, &stamp), previous.as_deref()));
|
||||
assert_eq!(mind.persona().unwrap().unwrap().receipt_id, receipt, "after put {second}");
|
||||
previous = Some(stamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persona_document_is_validated_at_the_door() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
let before = store.rows();
|
||||
|
||||
for name in schema_file()["required"].as_array().unwrap() {
|
||||
let name = name.as_str().unwrap();
|
||||
let mut document = persona(INSTANCE, T1);
|
||||
document.as_object_mut().unwrap().remove(name);
|
||||
assert_eq!(refused(put(&mut mind, document, None)), invalid(&format!("/{name}"), "required"), "{name}");
|
||||
}
|
||||
|
||||
let mut extra = persona(INSTANCE, T1);
|
||||
extra["notInTheSchema"] = json!(1);
|
||||
assert_eq!(refused(put(&mut mind, extra, None)), invalid("/*", "additionalProperties"));
|
||||
|
||||
let mut wrong_type = persona(INSTANCE, T1);
|
||||
wrong_type["publicName"] = json!(5);
|
||||
assert_eq!(refused(put(&mut mind, wrong_type, None)), invalid("/publicName", "type"));
|
||||
|
||||
let mut bad_stamp = persona(INSTANCE, T1);
|
||||
bad_stamp["updatedAt"] = json!("yesterday");
|
||||
assert_eq!(refused(put(&mut mind, bad_stamp, None)), invalid("/updatedAt", "format"));
|
||||
|
||||
let mut nested = persona(INSTANCE, T1);
|
||||
nested["thoughtMemory"]["memories"] = json!([7]);
|
||||
assert_eq!(refused(put(&mut mind, nested, None)), invalid("/thoughtMemory/memories/0", "type"));
|
||||
|
||||
let mut free_key = persona(INSTANCE, T1);
|
||||
free_key["activationProfile"]["voiceStyle"]["any-key"] = json!({});
|
||||
assert_eq!(
|
||||
refused(put(&mut mind, free_key, None)),
|
||||
invalid("/activationProfile/voiceStyle/*/mean", "required"),
|
||||
"a key the schema does not declare is withheld from the path"
|
||||
);
|
||||
|
||||
assert_eq!(store.rows(), before, "no refusal wrote anything");
|
||||
assert_eq!(mind.persona().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_never_echoes_what_the_caller_sent() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
committed(put(&mut mind, persona(INSTANCE, T2), None));
|
||||
|
||||
let mut free_key = persona(INSTANCE, T3);
|
||||
free_key["activationProfile"]["voiceStyle"][CANARY] = json!({});
|
||||
let mut extra_key = persona(INSTANCE, T3);
|
||||
extra_key[CANARY] = json!(CANARY);
|
||||
let mut bad_value = persona(INSTANCE, T3);
|
||||
bad_value["updatedAt"] = json!(CANARY);
|
||||
let mut bad_enum = persona(INSTANCE, T3);
|
||||
bad_enum["provenance"]["authority"] = json!(CANARY);
|
||||
let mut foreign = persona(CANARY, T3);
|
||||
foreign["publicName"] = json!(CANARY);
|
||||
let mut imported = persona(INSTANCE, T3);
|
||||
imported["provenance"]["authority"] = json!("import");
|
||||
imported["provenance"]["sourceSystem"] = json!(CANARY);
|
||||
let mut late = persona(INSTANCE, T1);
|
||||
late["publicName"] = json!(CANARY);
|
||||
|
||||
let cases: Vec<(Value, Option<&str>)> = vec![
|
||||
(free_key, Some(T2)),
|
||||
(extra_key, Some(T2)),
|
||||
(bad_value, Some(T2)),
|
||||
(bad_enum, Some(T2)),
|
||||
(foreign, Some(T2)),
|
||||
(imported, Some(T2)),
|
||||
(late, Some(T2)),
|
||||
(persona(INSTANCE, T3), Some(CANARY)),
|
||||
];
|
||||
for (document, expected) in cases {
|
||||
let refusal = refused(put(&mut mind, document, expected));
|
||||
assert!(!format!("{refusal:?}").contains(CANARY), "{refusal:?}");
|
||||
assert!(!serde_json::to_string(&refusal).unwrap().contains(CANARY));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_id_must_be_the_instance() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
let before = store.rows();
|
||||
let refusal = refused(put(&mut mind, persona(OTHER_INSTANCE, T1), None));
|
||||
assert_eq!(refusal, MindRefusal::PersonaForeign { instance: INSTANCE.into() });
|
||||
assert_eq!(store.rows(), before);
|
||||
assert_eq!(mind.persona().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_canonical_document_is_the_minds_persona() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
for authority in ["projection", "import"] {
|
||||
let mut document = persona(INSTANCE, T1);
|
||||
document["provenance"]["authority"] = json!(authority);
|
||||
assert_eq!(refused(put(&mut mind, document, None)), MindRefusal::PersonaNotCanonical, "{authority}");
|
||||
}
|
||||
assert_eq!(mind.persona().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persona_waits_for_the_minds_identity() {
|
||||
let mut mind = opened(MemoryStore::new(), INSTANCE);
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), None)), MindRefusal::MissingIdentity);
|
||||
assert!(mind.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_put_must_name_this_mind_and_a_named_asker() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
let before = store.rows();
|
||||
let other = slug(OTHER_INSTANCE);
|
||||
let outcome = mind.put_persona(&other, persona(INSTANCE, T1), None, provenance(Faculty::Hands), crate::fixtures::now());
|
||||
assert!(matches!(refused(outcome), MindRefusal::ForeignInstance { .. }));
|
||||
let mut unnamed = provenance(Faculty::Hands);
|
||||
unnamed.agent = eureka_pipeline::Short(" ".into());
|
||||
let instance = mind.instance().clone();
|
||||
let outcome = mind.put_persona(&instance, persona(INSTANCE, T1), None, unnamed, crate::fixtures::now());
|
||||
assert!(matches!(refused(outcome), MindRefusal::Document(_)));
|
||||
assert_eq!(store.rows(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_put_is_a_compare_and_swap_on_updated_at() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Absent);
|
||||
let receipts = |mind: &Mind<MemoryStore>| mind.receipts().unwrap().len();
|
||||
let base = receipts(&mind);
|
||||
|
||||
// A first put expects nothing; claiming a stored document is stale.
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), Some(T1))), stale(None));
|
||||
let first = committed(put(&mut mind, persona(INSTANCE, T1), None));
|
||||
assert_eq!(receipts(&mind), base + 1);
|
||||
let view = mind.persona().unwrap().unwrap();
|
||||
assert_eq!((view.updated_at.as_str(), view.receipt_id.as_str()), (T1, first.as_str()));
|
||||
assert_eq!(view.value, persona(INSTANCE, T1));
|
||||
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Stored { updated_at: T1.into() });
|
||||
|
||||
// Two puts that read the same stored document: the second loses.
|
||||
let second = committed(put(&mut mind, persona(INSTANCE, T2), Some(T1)));
|
||||
assert_eq!(receipts(&mind), base + 2);
|
||||
let rows = store.rows();
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T3), Some(T1))), stale(Some(T2)));
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T3), None)), stale(Some(T2)));
|
||||
assert_eq!(store.rows(), rows, "a stale put wrote nothing");
|
||||
|
||||
// The right expectation with an updatedAt that is not later.
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T2), Some(T2))), not_later());
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), Some(T2))), not_later());
|
||||
// Instants compare, not spellings: the same instant at another offset is not later.
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, "2026-10-10T12:00:01+02:00"), Some(T2))), not_later());
|
||||
assert_eq!(store.rows(), rows);
|
||||
assert_eq!(receipts(&mind), base + 2);
|
||||
committed(put(&mut mind, persona(INSTANCE, "2026-10-10T12:00:02+02:00"), Some(T2)));
|
||||
assert_eq!(receipts(&mind), base + 3);
|
||||
|
||||
// The second put's receipt read the first document and wrote the second.
|
||||
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == second).unwrap();
|
||||
assert_eq!((receipt.strong_reads.len(), receipt.writes.len()), (1, 1));
|
||||
assert_eq!(receipt.strong_reads[0].document_key, INSTANCE);
|
||||
assert_ne!(receipt.strong_reads[0].payload_msgpack, receipt.writes[0].payload_msgpack);
|
||||
assert_eq!(receipt.provenance, provenance(Faculty::Hands));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_put_whose_swap_is_lost_is_stale_and_changes_nothing() {
|
||||
let store = RefusingStore::new();
|
||||
let mut mind = opened(store.clone(), INSTANCE);
|
||||
seed(&mut mind);
|
||||
committed(put(&mut mind, persona(INSTANCE, T1), None));
|
||||
let rows = store.rows();
|
||||
store.command(SwapCommand::Lose);
|
||||
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T2), Some(T1))), stale(Some(T1)));
|
||||
assert_eq!(store.rows(), rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_persona_is_not_a_pipeline_document_and_a_batch_cannot_carry_it() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
let documents = mind.status(crate::wire::IndexStatus::Current).documents;
|
||||
committed(put(&mut mind, persona(INSTANCE, T1), None));
|
||||
assert_eq!(mind.status(crate::wire::IndexStatus::Current).documents, documents);
|
||||
assert!(PipelineKind::ALL.iter().all(|kind| kind.type_id() != HuginnPersonaEntry::TYPE));
|
||||
let forged = mind.persona_envelope().unwrap().clone();
|
||||
let instance = mind.instance().clone();
|
||||
let outcome = mind.admit_prepared(&instance, provenance(Faculty::Hands), vec![forged], crate::fixtures::now());
|
||||
assert!(matches!(outcome, crate::admission::PipelineAdmissionOutcome::Refused(_)), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_persona_per_mind_and_the_store_still_opens() {
|
||||
let yggdrasil = slug(INSTANCE);
|
||||
let cache = crate::mind::schema_cache().unwrap();
|
||||
let entry = |key: &str, value: Value| cache.prepare_entry_named(key, &HuginnPersonaEntry { value }).unwrap().0;
|
||||
let planted = |rows: Vec<CultCacheEnvelope>| {
|
||||
let store = MemoryStore::new();
|
||||
rows.into_iter().for_each(|row| store.plant(row));
|
||||
store
|
||||
};
|
||||
let base = || vec![epoch(), prepare(&instance(INSTANCE))];
|
||||
|
||||
// A store written before this type existed opens unchanged.
|
||||
assert!(Mind::open_with(planted(base()), &yggdrasil).is_ok());
|
||||
|
||||
// One persona opens; a planted row has no writing receipt, so the door and the status both call it unreadable.
|
||||
let mut with_one = base();
|
||||
with_one.push(entry(INSTANCE, persona(INSTANCE, T1)));
|
||||
let mind = Mind::open_with(planted(with_one.clone()), &yggdrasil).unwrap();
|
||||
assert_eq!(mind.envelopes().len(), 3);
|
||||
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Unreadable);
|
||||
assert!(mind.persona().is_err());
|
||||
|
||||
// Two are no mind's.
|
||||
with_one.push(entry("second", persona(INSTANCE, T2)));
|
||||
assert_eq!(
|
||||
Mind::open_with(planted(with_one), &yggdrasil).err(),
|
||||
Some(MindRefusal::ForeignStore { r#type: HuginnPersonaEntry::TYPE.into() })
|
||||
);
|
||||
// The opener still refuses a type that is not one of the seventeen.
|
||||
let mut stranger = base();
|
||||
let mut foreign = entry(INSTANCE, persona(INSTANCE, T1));
|
||||
foreign.r#type = "gamecult.persona_state.v1".into();
|
||||
stranger.push(foreign);
|
||||
assert!(matches!(Mind::open_with(planted(stranger), &yggdrasil).err(), Some(MindRefusal::ForeignStore { .. })));
|
||||
}
|
||||
|
||||
/// Status answers in the door's own terms: a stored persona the door
|
||||
/// cannot read is `Unreadable`, not the `Absent` of a mind with none, while
|
||||
/// `PersonaGet` answers `Unavailable` for the same bytes.
|
||||
#[test]
|
||||
fn status_says_unreadable_where_the_door_says_unavailable() {
|
||||
let yggdrasil = slug(INSTANCE);
|
||||
let cache = crate::mind::schema_cache().unwrap();
|
||||
let readable = cache.prepare_entry_named(INSTANCE, &HuginnPersonaEntry { value: persona(INSTANCE, T1) }).unwrap().0;
|
||||
let no_stamp = cache.prepare_entry_named(INSTANCE, &HuginnPersonaEntry { value: json!({}) }).unwrap().0;
|
||||
let mut undecodable = readable.clone();
|
||||
undecodable.payload = vec![0xc1];
|
||||
// Planted rows have no writing receipt: the door cannot read them, so neither may the status.
|
||||
for (row, status) in [
|
||||
(readable, crate::wire::PersonaStatus::Unreadable),
|
||||
(no_stamp, crate::wire::PersonaStatus::Unreadable),
|
||||
(undecodable, crate::wire::PersonaStatus::Unreadable),
|
||||
] {
|
||||
let store = MemoryStore::new();
|
||||
store.plant(epoch());
|
||||
store.plant(prepare(&instance(INSTANCE)));
|
||||
store.plant(row);
|
||||
let mind = Mind::open_with(store, &yggdrasil).unwrap();
|
||||
assert_eq!(persona_in_status(&mind), status);
|
||||
assert!(matches!(mind.persona(), Err(MindRefusal::Unavailable { .. })), "the door answers what the status says");
|
||||
}
|
||||
// A row a put wrote: status and door agree it is stored, with one stamp.
|
||||
let store = MemoryStore::new();
|
||||
let mut written = seeded_over(&store);
|
||||
committed(put(&mut written, persona(INSTANCE, T1), None));
|
||||
assert_eq!(persona_in_status(&written), crate::wire::PersonaStatus::Stored { updated_at: T1.into() });
|
||||
assert_eq!(written.persona().unwrap().unwrap().updated_at, T1);
|
||||
assert_eq!(persona_in_status(&seeded_over(&MemoryStore::new())), crate::wire::PersonaStatus::Absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_bytes_are_the_one_slot_array_the_catalogue_describes() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = seeded_over(&store);
|
||||
committed(put(&mut mind, persona(INSTANCE, T1), None));
|
||||
let envelope = mind.persona_envelope().unwrap();
|
||||
assert_eq!((envelope.r#type.as_str(), envelope.key.as_str()), ("gamecult.persona_state.v0", INSTANCE));
|
||||
let slots: (Value,) = rmp_serde::from_slice(&envelope.payload).unwrap();
|
||||
assert_eq!(slots.0, persona(INSTANCE, T1));
|
||||
}
|
||||
}
|
||||
|
|
@ -256,9 +256,15 @@ pub(crate) fn commit<S: MindStore>(
|
|||
.map(|(envelope, _)| envelope)
|
||||
.map_err(unavailable)?;
|
||||
// The store requires every expected identity to be replaced, so each
|
||||
// strong read is re-inserted unchanged beside the writes.
|
||||
// strong read is re-inserted unchanged beside the writes, unless a write
|
||||
// replaces it (a persona put reads the document it overwrites).
|
||||
let mut replacements = writes.clone();
|
||||
replacements.extend(strong_reads.iter().cloned());
|
||||
replacements.extend(
|
||||
strong_reads
|
||||
.iter()
|
||||
.filter(|read| !writes.iter().any(|write| write.r#type == read.r#type && write.key == read.key))
|
||||
.cloned(),
|
||||
);
|
||||
replacements.push(receipt_envelope);
|
||||
let expected: &[CultCacheEnvelope] = &strong_reads;
|
||||
let landed = MindStore::compare_and_swap_batch(mind.store(), expected, replacements).map_err(unavailable)?;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,19 @@ pub enum MindRefusal {
|
|||
FindingWithoutEvidence,
|
||||
UnknownInvariant { label: String },
|
||||
NotStewarded { repo: String },
|
||||
/// The persona document fails the published schema: `path` is the
|
||||
/// document location (a key the schema does not declare is `*`) and
|
||||
/// `message` the schema keyword that failed. Never the offending value.
|
||||
PersonaInvalid { path: String, message: String },
|
||||
/// The document's `personaId` is not this mind's instance, which is
|
||||
/// `instance`.
|
||||
PersonaForeign { instance: String },
|
||||
/// The put's `expected_updated_at` is not the stored document's
|
||||
/// `updatedAt`, which is `stored` (none when no persona is stored).
|
||||
PersonaStale { stored: Option<String> },
|
||||
/// The document's `provenance.authority` is not `canonical`: a projection
|
||||
/// or an import is not the mind's persona.
|
||||
PersonaNotCanonical,
|
||||
/// A run claims work another run in force already holds: `item` is the
|
||||
/// claimed document's id and `run` the id of the run that holds it.
|
||||
AlreadyClaimed { item: String, run: RunId },
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ use cultnet_rs::{CultMeshCdnArtifactManifest, Selection};
|
|||
use eureka_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineKind, PipelineRef, Slug};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome};
|
||||
use crate::mind::Mind;
|
||||
use crate::persona::{PersonaPutOutcome, PersonaStateView};
|
||||
use crate::query::{PipelineDocumentView, PipelineSelectionPage, SemanticQuery};
|
||||
use crate::receipt::HuginnCommitReceipt;
|
||||
use crate::receipt::{HuginnCommitReceipt, PipelineProvenance};
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::store::MindStore;
|
||||
|
||||
|
|
@ -75,6 +77,17 @@ pub enum HuginnMindRequest {
|
|||
selection: Selection,
|
||||
semantic: Option<SemanticQuery>,
|
||||
},
|
||||
/// The mind's persona document, if one is stored.
|
||||
PersonaGet { instance: Slug },
|
||||
/// Replaces the mind's persona document whole. `expected_updated_at` is
|
||||
/// the stored document's `updatedAt`, none for the first put. The
|
||||
/// provenance is the asker's, as for `Admit`.
|
||||
PersonaPut {
|
||||
instance: Slug,
|
||||
provenance: PipelineProvenance,
|
||||
state: Value,
|
||||
expected_updated_at: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HuginnMindRequest {
|
||||
|
|
@ -85,6 +98,8 @@ impl HuginnMindRequest {
|
|||
Self::Admit(_) => "admit",
|
||||
Self::View { .. } => "view",
|
||||
Self::Query { .. } => "query",
|
||||
Self::PersonaGet { .. } => "persona_get",
|
||||
Self::PersonaPut { .. } => "persona_put",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +108,10 @@ impl HuginnMindRequest {
|
|||
match self {
|
||||
Self::Whoami => None,
|
||||
Self::Admit(batch) => Some(&batch.instance),
|
||||
Self::View { instance, .. } | Self::Query { instance, .. } => Some(instance),
|
||||
Self::View { instance, .. }
|
||||
| Self::Query { instance, .. }
|
||||
| Self::PersonaGet { instance }
|
||||
| Self::PersonaPut { instance, .. } => Some(instance),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +126,8 @@ pub enum HuginnMindResponse {
|
|||
View(Option<Box<PipelineDocumentView>>),
|
||||
Query(PipelineSelectionPage),
|
||||
Refused(MindRefusal),
|
||||
Persona(Option<PersonaStateView>),
|
||||
PersonaPut(PersonaPutOutcome),
|
||||
/// The answer did not fit one send and is being held for the client to
|
||||
/// fetch. Nothing in a mind produces this: the daemon's serve module does,
|
||||
/// in place of an answer, and the answer it stands for is the named
|
||||
|
|
@ -134,7 +154,9 @@ impl HuginnMindResponse {
|
|||
/// envelope of a deferred answer to the status of the answer it stands for.
|
||||
pub fn status(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Refused(_) | Self::Admit(PipelineAdmissionOutcome::Refused(_)) => "rejected",
|
||||
Self::Refused(_)
|
||||
| Self::Admit(PipelineAdmissionOutcome::Refused(_))
|
||||
| Self::PersonaPut(PersonaPutOutcome::Refused(_)) => "rejected",
|
||||
_ => "accepted",
|
||||
}
|
||||
}
|
||||
|
|
@ -170,6 +192,18 @@ pub struct MindStatus {
|
|||
pub documents: u32,
|
||||
pub receipts: u32,
|
||||
pub index: IndexStatus,
|
||||
/// The persona door as `PersonaGet` would find it.
|
||||
pub persona: PersonaStatus,
|
||||
}
|
||||
|
||||
/// What the persona door holds, in the one vocabulary the door itself answers
|
||||
/// in: nothing yet, a document with this `updatedAt`, or a stored document the
|
||||
/// door cannot read (`PersonaGet` and every put answer `Unavailable`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum PersonaStatus {
|
||||
Absent,
|
||||
Stored { updated_at: String },
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl<S: MindStore> Mind<S> {
|
||||
|
|
@ -191,6 +225,11 @@ impl<S: MindStore> Mind<S> {
|
|||
documents,
|
||||
receipts,
|
||||
index,
|
||||
persona: match self.persona() {
|
||||
Ok(None) => PersonaStatus::Absent,
|
||||
Ok(Some(view)) => PersonaStatus::Stored { updated_at: view.updated_at },
|
||||
Err(_) => PersonaStatus::Unreadable,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +252,13 @@ mod tests {
|
|||
HuginnMindRequest::Admit(batch),
|
||||
HuginnMindRequest::View { instance: slug(INSTANCE), id: r(PipelineKind::Campaign, CAMPAIGN) },
|
||||
HuginnMindRequest::Query { instance: slug(OTHER_INSTANCE), selection: Selection::default(), semantic: None },
|
||||
HuginnMindRequest::PersonaGet { instance: slug(INSTANCE) },
|
||||
HuginnMindRequest::PersonaPut {
|
||||
instance: slug(INSTANCE),
|
||||
provenance: provenance(Faculty::Hands),
|
||||
state: serde_json::json!({ "updatedAt": "2026-10-10T10:00:00Z", "n": [1, 2.5, null, "x"] }),
|
||||
expected_updated_at: Some("2026-10-10T09:00:00Z".into()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +278,14 @@ mod tests {
|
|||
edges: None,
|
||||
}),
|
||||
HuginnMindResponse::Refused(refusal),
|
||||
HuginnMindResponse::Persona(None),
|
||||
HuginnMindResponse::Persona(Some(PersonaStateView {
|
||||
value: serde_json::json!({ "personaId": INSTANCE }),
|
||||
updated_at: "2026-10-10T10:00:00Z".into(),
|
||||
receipt_id: "mind-commit-1".into(),
|
||||
})),
|
||||
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "mind-commit-2".into() }),
|
||||
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(MindRefusal::PersonaNotCanonical)),
|
||||
HuginnMindResponse::Deferred(DeferredAnswer {
|
||||
manifest: cultnet_rs::pack_content("huginn.mind_response", "package", "", "", "", b"body", 4)
|
||||
.unwrap()
|
||||
|
|
@ -246,7 +300,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_wire_vocabulary_is_the_minds_methods_and_status_is_derived_from_the_response() {
|
||||
let names: Vec<&str> = requests().iter().map(|request| request.operation()).collect();
|
||||
assert_eq!(names, ["whoami", "admit", "view", "query"]);
|
||||
assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put"]);
|
||||
for request in requests() {
|
||||
let declared = request.instance().is_none();
|
||||
assert_eq!(declared, matches!(request, HuginnMindRequest::Whoami), "{request:?}");
|
||||
|
|
@ -262,7 +316,9 @@ mod tests {
|
|||
for response in responses() {
|
||||
let rejected = matches!(
|
||||
response,
|
||||
HuginnMindResponse::Refused(_) | HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(_))
|
||||
HuginnMindResponse::Refused(_)
|
||||
| HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(_))
|
||||
| HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(_))
|
||||
);
|
||||
assert_eq!(response.status(), if rejected { "rejected" } else { "accepted" }, "{response:?}");
|
||||
let bytes = rmp_serde::to_vec_named(&response).unwrap();
|
||||
|
|
|
|||
|
|
@ -78,6 +78,60 @@
|
|||
"required": [
|
||||
"Query"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The mind's persona document, if one is stored.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaGet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"instance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaGet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Replaces the mind's persona document whole. `expected_updated_at` is\nthe stored document's `updatedAt`, none for the first put. The\nprovenance is the asker's, as for `Admit`.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaPut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expected_updated_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/$defs/PipelineProvenance"
|
||||
},
|
||||
"state": true
|
||||
},
|
||||
"required": [
|
||||
"instance",
|
||||
"provenance",
|
||||
"state"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaPut"
|
||||
]
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,37 @@
|
|||
"Refused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Persona": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/PersonaStateView"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Persona"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaPut": {
|
||||
"$ref": "#/$defs/PersonaPutOutcome"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaPut"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The answer did not fit one send and is being held for the client to\nfetch. Nothing in a mind produces this: the daemon's serve module does,\nin place of an answer, and the answer it stands for is the named\nMessagePack of the `HuginnMindResponse` the mind gave.",
|
||||
"type": "object",
|
||||
|
|
@ -1360,6 +1391,78 @@
|
|||
"NotStewarded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The persona document fails the published schema: `path` is the\ndocument location (a key the schema does not declare is `*`) and\n`message` the schema keyword that failed. Never the offending value.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaInvalid": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaInvalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The document's `personaId` is not this mind's instance, which is\n`instance`.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaForeign": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instance": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"instance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaForeign"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The put's `expected_updated_at` is not the stored document's\n`updatedAt`, which is `stored` (none when no persona is stored).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"PersonaStale": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stored": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"PersonaStale"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The document's `provenance.authority` is not `canonical`: a projection\nor an import is not the mind's persona.",
|
||||
"type": "string",
|
||||
"const": "PersonaNotCanonical"
|
||||
},
|
||||
{
|
||||
"description": "A run claims work another run in force already holds: `item` is the\nclaimed document's id and `run` the id of the run that holds it.",
|
||||
"type": "object",
|
||||
|
|
@ -1525,6 +1628,10 @@
|
|||
"instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"persona": {
|
||||
"description": "The persona door as `PersonaGet` would find it.",
|
||||
"$ref": "#/$defs/PersonaStatus"
|
||||
},
|
||||
"receipts": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
|
|
@ -1539,7 +1646,8 @@
|
|||
"schema_epoch",
|
||||
"documents",
|
||||
"receipts",
|
||||
"index"
|
||||
"index",
|
||||
"persona"
|
||||
]
|
||||
},
|
||||
"MutationRecord": {
|
||||
|
|
@ -1603,6 +1711,93 @@
|
|||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"PersonaPutOutcome": {
|
||||
"description": "How a put ended. A refusal is an answer, as for `admit`.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Committed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"receipt_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"receipt_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Committed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Refused": {
|
||||
"$ref": "#/$defs/MindRefusal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Refused"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PersonaStateView": {
|
||||
"description": "What `PersonaGet` answers: the stored document, its `updatedAt`, and the\nreceipt of the put that wrote it.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"receipt_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": true
|
||||
},
|
||||
"required": [
|
||||
"value",
|
||||
"updated_at",
|
||||
"receipt_id"
|
||||
]
|
||||
},
|
||||
"PersonaStatus": {
|
||||
"description": "What the persona door holds, in the one vocabulary the door itself answers\nin: nothing yet, a document with this `updatedAt`, or a stored document the\ndoor cannot read (`PersonaGet` and every put answer `Unavailable`).",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Absent",
|
||||
"Unreadable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Stored": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"updated_at"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Stored"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PipelineAdmissionOutcome": {
|
||||
"description": "How admission ended. `Committed.writes` names every document the batch\nlanded, derived writes included; Cut 11 indexes from it after `admit`\nreturns, and never inside it.",
|
||||
"oneOf": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue