Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58809bdbc | ||
|
|
84cb5904df | ||
|
|
fc52d72a01 | ||
|
|
a7186173b4 |
+6
-3
@@ -12,9 +12,12 @@ All notable changes to the published WIP Rust crates are documented here.
|
||||
local names, with coherent provider-attested scope Object binding for fetch and
|
||||
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||
- Migrates FS root publication and public-example self-scopes to the Host API,
|
||||
including coherent backend observations and explicit built-in names. Non-root
|
||||
FS semantics and Client cache recovery remain follow-up work. See
|
||||
- Integrates scoped references across filesystem and public consumers and the TUI.
|
||||
External FS scopes bind to visible current connected lineage, not retained
|
||||
registry artifacts. TUI labels use absolute safely quoted `::`/`#` notation;
|
||||
structured selection and Client scope invalidation prevent stale UI dispatch.
|
||||
The public example carries fetched scope refs and includes real HTTP lifetime,
|
||||
visibility and validator-precedence conformance tests with optional refs. See
|
||||
[MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Generated
+2
@@ -1301,7 +1301,9 @@ version = "0.1.0"
|
||||
name = "wip-public-api-example"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wip-client",
|
||||
"wip-host",
|
||||
"wip-http",
|
||||
"wip-interface",
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
+31
-15
@@ -46,9 +46,11 @@ still contains only format, documentation, types and operations, not identity.
|
||||
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||
not an invented identity. This is an opaque scope Object ref observation/call
|
||||
precondition, **not** a third Interface identity component or a validator.
|
||||
Host now enforces scope publication lifetimes and optional scope-ref identity
|
||||
inside the provider boundary (T-21). Client cache-recovery semantics remain
|
||||
follow-up work (T-22); do not mistake registry artifact retention for publication.
|
||||
Host enforces scope publication lifetimes and optional scope-ref identity
|
||||
inside the provider boundary. Client caches keep the complete structured reference
|
||||
and optional fetched scope binding; confirmed scope deletion or ref replacement
|
||||
invalidates bound observations, while local eviction is not deletion evidence.
|
||||
Do not mistake registry artifact retention for publication.
|
||||
|
||||
## Validation boundaries
|
||||
|
||||
@@ -176,17 +178,31 @@ the latter also validates compatible live scope associations and response shape.
|
||||
|
||||
## Downstream status
|
||||
|
||||
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write`
|
||||
at its deployment-pinned root Scope. It binds that lifetime from the refreshed
|
||||
retained root lineage inside the backend execution boundary for calls, generic
|
||||
Object observations and fetch. A textual Linux root rename/replacement does not
|
||||
substitute the pinned root Object. The root has no Object ref, so fetch omits
|
||||
scope_ref; the private lifetime check still applies. Non-root FS registrations
|
||||
fail closed pending broader FS/TUI semantics integration (T-23).
|
||||
The filesystem default registers local names `file.read`, `symlink.inspect` and
|
||||
`file.write` at its deployment-pinned root Scope, displayed as `/::file.read` etc.
|
||||
External compositions choose explicit Scope/local names and implement
|
||||
`Composition::scope` to attest a non-root lifetime from the exact refreshed backend
|
||||
entry in its connected lineage. The engine never independently resolves a scope
|
||||
path during dispatch. Default composition remains root-scoped; exposing non-root
|
||||
registrations requires the application's current lifetime attestation, not merely
|
||||
an artifact or matching ref/validator. All bindings are formed inside backend
|
||||
execution, with current principal lineage visibility and publication metadata.
|
||||
Retire/recreate Scope handles on deletion, replacement or movement; a pinned leaf
|
||||
alone is not evidence that a self-scope Object remains published at that path.
|
||||
The Linux deployment root remains pinned across textual root rename/replacement,
|
||||
and its ref-less default publication continues to omit scope_ref.
|
||||
|
||||
The public API example self-scopes `catalog`, `audit` and `diagnostics` at
|
||||
`/article-service`, fetches through its provider, and carries the observed scope
|
||||
ref in calls. Client keys use structured values; scope deletion/replacement
|
||||
cache recovery remains T-22. This Host change does not modify TUI formatting;
|
||||
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain
|
||||
descriptor-only and need no identity fields or declaration changes.
|
||||
`/article-service`, fetches through its provider, and constructs call metadata
|
||||
from the fetched structured reference and scope_ref. External-style conformance
|
||||
tests also cross real TCP HTTP for root/self-scopes, same names in different
|
||||
scopes, optional refs, stale refs, deletion/republication and validator precedence.
|
||||
|
||||
The TUI stores structured references in cache keys, selections, actions, editors
|
||||
and in-flight subjects. Normal screens reuse the canonical absolute scoped
|
||||
formatter (`/scope::name#operation`, with JSON component quoting when needed).
|
||||
Labels are never parsed to reconstruct identity, and scope_ref/validators are not
|
||||
normal-screen reference labels. Client invalidation is synchronized back into UI
|
||||
observations before dispatch; re-observation is not automatic execution. Typed
|
||||
result entries are still fetched on selection, not all at once, and do not create
|
||||
inferred indexable edges. Interface authoring/macros remain descriptor-only.
|
||||
|
||||
@@ -85,6 +85,13 @@ The unreleased Protocol/HTTP API uses explicit `InterfaceReference { scope,
|
||||
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||
for construction examples, validation boundaries and downstream migration status.
|
||||
The filesystem default publishes `/::file.read`, `/::symlink.inspect` and
|
||||
`/::file.write`; external compositions explicitly choose Scope/local names.
|
||||
The TUI retains structured references for selection and dispatch, while normal
|
||||
screens use absolute `scope::name#operation` labels with safely quoted components.
|
||||
These labels are display-only: they are never parsed as cache keys or call targets,
|
||||
and scope refs/validators remain runtime metadata rather than normal-screen labels.
|
||||
Client scope invalidation is reflected in the UI before another explicit call.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ envelopes, and HTTP status mapping.
|
||||
|
||||
## Library assembly and migration
|
||||
|
||||
The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire
|
||||
representation remain compatible. The formerly private projection has become a
|
||||
public, generic library engine. No package version or publication policy changes
|
||||
are required.
|
||||
`ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their
|
||||
assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration:
|
||||
Interface references are `{scope, name}` values, registration takes a `Scope`
|
||||
and local name, and calls use `interface.reference` plus optional `scope_ref`.
|
||||
Fetch returns the same structured reference and current scope Object's optional
|
||||
ref. This is the standard WIP wire format, not an FS-specific profile.
|
||||
|
||||
Use `default_projection(root, access, limits)` to obtain the default Linux engine
|
||||
without starting a listener. `projection.view(&principal)` is the effective view;
|
||||
@@ -30,9 +32,11 @@ For an external backend/composition:
|
||||
register generated definitions with `registry.register(&scope, name,
|
||||
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
|
||||
optionally supplies reusable built-in tokens named `file.read`,
|
||||
`symlink.inspect`, and `file.write`; tokens do not force publication. The
|
||||
current engine binds only the root scope and fails closed for non-root
|
||||
registrations; broader FS scope semantics remain follow-up work.
|
||||
`symlink.inspect`, and `file.write`; tokens do not force publication. For a
|
||||
non-root scope, create a handle at its canonical WIP path and implement
|
||||
`Composition::scope(path, &entry, context)` to attest that handle only for
|
||||
its current Object lifetime. Local names are exact names, not parsed `::`/`#`
|
||||
display strings; the same local name can be registered at different scopes.
|
||||
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||
@@ -78,6 +82,29 @@ It never independently looks up the raw configured root path. Root Objects need
|
||||
not expose any interfaces. Calls check both the private lifetime and optional
|
||||
`scope_ref`; normal root state updates do not retire the lifetime.
|
||||
|
||||
`Composition::scope` is the trusted non-root lifetime attestation hook. It runs
|
||||
only for registered scope paths in the refreshed, principal-visible execution
|
||||
lineage, never through independent scope-path resolution. Return `Some(scope)`
|
||||
only when the retained entry is still the current Object published at that path;
|
||||
use retained backend identity and application publication-lifecycle state under
|
||||
the backend boundary, not ref or validator equality. Deletion, replacement or
|
||||
movement needs a fresh handle and registration, even if a ref is absent or reused.
|
||||
Ordinary state changes retain the handle. A different returned path is an internal
|
||||
publication failure; `None` or a fresh unregistered lifetime hides old artifacts.
|
||||
The default hook returns `None`, so existing compositions fail closed outside `/`.
|
||||
|
||||
Self-scope needs special care: a backend may allow ordinary execution against an
|
||||
unlinked/replaced pinned leaf, but that does not establish that leaf as the current
|
||||
scope Object. The hook must attest current membership as well as retained identity
|
||||
within the same boundary. The default Linux composition does not attest non-root
|
||||
lifetimes or invent inode-based Object refs. External compositions must provide
|
||||
that guarantee themselves, including their documented external-writer limits.
|
||||
Scope metadata is obtained by publishing that retained entry (reusing the target
|
||||
publication for self-scope); scope Objects need not expose interfaces. Optional
|
||||
refs are taken from the published Object, not from a raw backend entry. Calls
|
||||
without `scope_ref` remain valid when the lifetime is current; supplied stale refs
|
||||
reject with `InterfaceMismatch` after Object validator checks.
|
||||
|
||||
`with_object` (including fetch) and individual tree Object publications also
|
||||
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
|
||||
as an adapter to the existing execution API; no typed operation runs and no
|
||||
|
||||
@@ -24,7 +24,9 @@ pub struct DefaultInterfaces {
|
||||
impl DefaultInterfaces {
|
||||
/// Register reusable descriptors at an explicit publication scope.
|
||||
///
|
||||
/// The current FS engine binds only its deployment root scope (`/`).
|
||||
/// Built-in local names are `file.read`, `symlink.inspect`, and `file.write`.
|
||||
/// The default deployment registers them at `/`; external compositions may
|
||||
/// register them at non-root scopes attested by `Composition::scope`.
|
||||
pub fn register(
|
||||
registry: &mut InterfaceRegistry,
|
||||
scope: &Scope,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::ResourceLimits;
|
||||
use wip_host::{
|
||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||
OperationAttempt, ProviderResult, PublishedObject, ScopeObservation, WipObject,
|
||||
OperationAttempt, ProviderResult, PublishedObject, Scope, ScopeObservation, WipObject,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||
@@ -75,8 +75,8 @@ impl<E> ResolvedEntry<E> {
|
||||
/// The root is a deployment-pinned Object: every resolution/execution lineage
|
||||
/// must retain that same root, even if its textual backend path is renamed or
|
||||
/// replaced. Replacing the deployment root requires a new projection and Scope
|
||||
/// lifetime. The engine currently binds only `/`; non-root registrations are
|
||||
/// not published until a backend can attest their Object lifetimes.
|
||||
/// lifetime. The engine binds `/` to this pinned deployment root. Non-root
|
||||
/// lifetimes are attested by [`Composition::scope`] from the refreshed lineage.
|
||||
///
|
||||
/// `execute` must retain the same target and complete lineage and serialize
|
||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||
@@ -126,12 +126,39 @@ pub trait FilesystemBackend {
|
||||
pub trait Composition<E: BackendEntry> {
|
||||
/// Local typed operation implementation and safe public metadata.
|
||||
type Object: WipObject;
|
||||
/// Attest the current non-root scope lifetime of this exact backend entry.
|
||||
///
|
||||
/// Called only within `FilesystemBackend::execute`, for refreshed entries in
|
||||
/// its connected lineage. Return the same handle used for registration only
|
||||
/// while this same Object remains published at `path`. After deletion,
|
||||
/// replacement or movement return `None` or a fresh Scope, even without an
|
||||
/// Object ref (or when the replacement reuses that ref). Ordinary state
|
||||
/// changes retain the handle. In particular a self-scope target must still
|
||||
/// be the current member at `path`: a backend's ordinary pinned-leaf guarantee
|
||||
/// alone may allow execution after leaf replacement and is not sufficient
|
||||
/// evidence for a scope lifetime. Do not re-resolve `path` or infer lifetime from
|
||||
/// a validator/ref alone: use the entry's retained backend identity and the
|
||||
/// application's publication lifecycle, serialized by the backend boundary.
|
||||
///
|
||||
/// The default attests no non-root scopes. The deployment-pinned root is
|
||||
/// bound automatically; this hook is not called for `/`. Returning a Scope
|
||||
/// for a different path is an internal publication error. A scope Object
|
||||
/// need not expose any interfaces and its public ref remains optional.
|
||||
fn scope(
|
||||
&self,
|
||||
_path: &str,
|
||||
_entry: &E,
|
||||
_context: &ExecutionContext,
|
||||
) -> Result<Option<Scope>, HostError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Build a request-scoped publication using this exact capability/context.
|
||||
///
|
||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||
/// backend. This hook may also publish the retained root to observe its scope
|
||||
/// metadata while another target is executing. Root publication must be
|
||||
/// deterministic within the view and use this same retained capability.
|
||||
/// backend. This hook may also publish retained scope ancestors to observe
|
||||
/// their metadata while another target is executing. Scope publication must
|
||||
/// be deterministic within the view and use this same retained capability.
|
||||
/// A publication failure is a pre-dispatch rejection, never a
|
||||
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||
fn publish(
|
||||
@@ -294,22 +321,78 @@ where
|
||||
|
||||
fn publication(
|
||||
&self,
|
||||
entry: B::Entry,
|
||||
root: &B::Entry,
|
||||
resolved: &ResolvedEntry<B::Entry>,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||
let is_root = entry.name().is_empty();
|
||||
let entry = resolved.target();
|
||||
let publication = self
|
||||
.projection
|
||||
.composition
|
||||
.publish(entry.clone(), context)?;
|
||||
// Scope refs describe the published root Object, not an independently
|
||||
// resolved raw path or the backend entry's possibly different metadata.
|
||||
let root_publication = if is_root {
|
||||
None
|
||||
} else {
|
||||
Some(self.projection.composition.publish(root.clone(), context)?)
|
||||
};
|
||||
let mut current_scopes = Vec::new();
|
||||
let mut path = String::from("/");
|
||||
// Only this refreshed, visible, connected lineage can attest scopes.
|
||||
// Never independently resolve a scope path while a target is executing.
|
||||
for (index, scope_entry) in resolved
|
||||
.ancestors()
|
||||
.iter()
|
||||
.chain(std::iter::once(entry))
|
||||
.enumerate()
|
||||
{
|
||||
if index > 0 {
|
||||
if path != "/" {
|
||||
path.push('/');
|
||||
}
|
||||
path.push_str(scope_entry.name());
|
||||
}
|
||||
if !self
|
||||
.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.any(|scope| scope.path() == path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let scopes = if path == "/" {
|
||||
self.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.filter(|scope| scope.path() == "/")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
self.projection
|
||||
.composition
|
||||
.scope(&path, scope_entry, context)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if scopes.iter().any(|scope| scope.path() != path) {
|
||||
return Err(internal());
|
||||
}
|
||||
if scopes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Refs belong to the Composition's published Object, not necessarily
|
||||
// the raw entry. Self-scope reuses exactly the target publication.
|
||||
let scope_publication = if index == resolved.ancestors().len() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.projection
|
||||
.composition
|
||||
.publish(scope_entry.clone(), context)?,
|
||||
)
|
||||
};
|
||||
let object = scope_publication
|
||||
.as_ref()
|
||||
.map_or(publication.implementation(), |scope| scope.implementation());
|
||||
current_scopes.extend(
|
||||
scopes
|
||||
.iter()
|
||||
.map(|scope| ScopeObservation::new(scope, object)),
|
||||
);
|
||||
}
|
||||
let available = publication
|
||||
.protocol_object()
|
||||
.interfaces
|
||||
@@ -324,15 +407,18 @@ where
|
||||
let mut selected = self
|
||||
.projection
|
||||
.policy
|
||||
.interfaces(self.principal, &entry, &available);
|
||||
.interfaces(self.principal, entry, &available);
|
||||
selected.retain(|reference| {
|
||||
// Non-root lifetimes are not attested by this engine yet. Never
|
||||
// publish an artifact merely because the registry retains it.
|
||||
reference.protocol_reference().scope == "/"
|
||||
&& self
|
||||
.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, reference)
|
||||
let reference_value = reference.protocol_reference();
|
||||
current_scopes.iter().any(|current| {
|
||||
self.projection
|
||||
.registry
|
||||
.fetch_interface(&reference_value, current)
|
||||
.is_ok()
|
||||
}) && self
|
||||
.projection
|
||||
.policy
|
||||
.interface_visible(self.principal, reference)
|
||||
});
|
||||
let mut publication = publication
|
||||
.project(&selected, |reference, operation| {
|
||||
@@ -341,19 +427,7 @@ where
|
||||
.operation_visible(self.principal, reference, operation)
|
||||
})
|
||||
.map_err(|_| internal())?;
|
||||
// Called only inside backend.execute, with its refreshed pinned lineage.
|
||||
// scopes() may repeat the handle for different names; bind_scope replaces
|
||||
// the same-path snapshot rather than treating repetition as a collision.
|
||||
for scope in self
|
||||
.projection
|
||||
.registry
|
||||
.scopes()
|
||||
.filter(|scope| scope.path() == "/")
|
||||
{
|
||||
let object = root_publication
|
||||
.as_ref()
|
||||
.map_or(publication.implementation(), |root| root.implementation());
|
||||
let current = ScopeObservation::new(scope, object);
|
||||
for current in current_scopes {
|
||||
publication = publication.bind_scope(current);
|
||||
}
|
||||
Ok(publication)
|
||||
@@ -382,10 +456,9 @@ where
|
||||
observed = Some(ProviderResult::NotFound);
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
observed = Some(
|
||||
match preflight(context).and_then(|()| {
|
||||
self.publication(resolved.target().clone(), root, context)
|
||||
self.publication(&resolved, context)
|
||||
.map(|publication| operation(&publication))
|
||||
}) {
|
||||
Ok(result) => ProviderResult::Found(result),
|
||||
@@ -601,8 +674,6 @@ where
|
||||
if !self.lineage_visible(&resolved) {
|
||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||
}
|
||||
let root = resolved.ancestors().first().unwrap_or(resolved.target());
|
||||
let entry = resolved.target().clone();
|
||||
if let Err(error) = preflight(context) {
|
||||
return OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
@@ -611,7 +682,7 @@ where
|
||||
}
|
||||
});
|
||||
}
|
||||
match self.publication(entry, root, context) {
|
||||
match self.publication(&resolved, context) {
|
||||
Ok(publication) => operation(&publication),
|
||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
|
||||
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
|
||||
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
|
||||
//! binds only its deployment-pinned root lifetime, inside the backend execution
|
||||
//! boundary, using the current published root Object from the retained lineage.
|
||||
//! Observation/fetch callbacks enter that same coherent boundary. Non-root
|
||||
//! registrations fail closed until their Object lifetimes can be attested.
|
||||
//! binds its deployment-pinned root lifetime automatically inside the backend
|
||||
//! execution boundary. External [`Composition::scope`] implementations attest
|
||||
//! non-root lifetimes using the refreshed connected lineage in that same boundary.
|
||||
//! Without an attestation non-root registrations fail closed. Scope refs come
|
||||
//! from the current published scope Object and remain optional.
|
||||
//! The Linux root survives textual root renames and does not issue an Object ref;
|
||||
//! fetch therefore omits `scope_ref` without weakening the private lifetime check.
|
||||
//!
|
||||
|
||||
@@ -125,6 +125,7 @@ struct Seen {
|
||||
#[derive(Default)]
|
||||
struct Controls {
|
||||
bump_before_execute: bool,
|
||||
bump_ancestor: Option<String>,
|
||||
hide_before_execute: bool,
|
||||
hide_ancestor: Option<String>,
|
||||
replace_ancestor: Option<String>,
|
||||
@@ -289,6 +290,9 @@ impl FilesystemBackend for MemoryBackend {
|
||||
entry.node.data.lock().unwrap().revision += 1;
|
||||
controls.bump_before_execute = false;
|
||||
}
|
||||
if let Some(path) = controls.bump_ancestor.take() {
|
||||
self.0.node(&path).data.lock().unwrap().revision += 1;
|
||||
}
|
||||
if controls.hide_before_execute {
|
||||
entry.node.data.lock().unwrap().hidden = true;
|
||||
}
|
||||
@@ -2188,8 +2192,11 @@ fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||
);
|
||||
}
|
||||
|
||||
#[path = "support/scoped.rs"]
|
||||
mod scoped;
|
||||
|
||||
#[test]
|
||||
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() {
|
||||
fn nonroot_registrations_fail_closed_without_composition_lifetime_attestation() {
|
||||
let fixture = Fixture::new();
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let root = Scope::new("/").unwrap();
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! External non-root scope regression coverage, sharing the memory backend.
|
||||
use super::*;
|
||||
|
||||
struct ScopedObject {
|
||||
inner: Object,
|
||||
reference: Option<String>,
|
||||
}
|
||||
impl WipObject for ScopedObject {
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
self.inner.validator()
|
||||
}
|
||||
fn requires_validator(&self, _: &InterfaceRef, operation: &str) -> bool {
|
||||
operation == "read"
|
||||
}
|
||||
}
|
||||
impl FileRead for ScopedObject {
|
||||
fn read(&self, offset: Option<u64>, length: Option<u64>) -> wip::OperationResult<wip::Bytes> {
|
||||
FileRead::read(&self.inner, offset, length)
|
||||
}
|
||||
}
|
||||
struct ScopedComposition {
|
||||
shared: Arc<Shared>,
|
||||
associations: BTreeMap<String, (Arc<Node>, Scope)>,
|
||||
interfaces: Vec<RegisteredInterface<wip_fs_server::FileReadDefinition>>,
|
||||
with_refs: bool,
|
||||
}
|
||||
impl Composition<Entry> for ScopedComposition {
|
||||
type Object = ScopedObject;
|
||||
fn scope(
|
||||
&self,
|
||||
path: &str,
|
||||
entry: &Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<Option<Scope>, HostError> {
|
||||
assert!(matches!(
|
||||
self.shared.transaction.try_lock(),
|
||||
Err(TryLockError::WouldBlock)
|
||||
));
|
||||
assert_eq!(path, entry.path);
|
||||
self.shared.record("scope", path, context);
|
||||
assert_eq!(
|
||||
entry.validator,
|
||||
Entry::snapshot(entry.path.clone(), entry.node.clone()).validator
|
||||
);
|
||||
let nodes = self.shared.nodes.lock().unwrap();
|
||||
// Self-scope also requires current membership; ordinary pinned-leaf
|
||||
// execution intentionally need not check its path mapping.
|
||||
Ok(self.associations.get(path).and_then(|(identity, scope)| {
|
||||
(Arc::ptr_eq(identity, &entry.node)
|
||||
&& nodes
|
||||
.get(path)
|
||||
.is_some_and(|node| Arc::ptr_eq(node, identity)))
|
||||
.then(|| scope.clone())
|
||||
}))
|
||||
}
|
||||
fn publish(
|
||||
&self,
|
||||
entry: Entry,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<PublishedObject<ScopedObject>, HostError> {
|
||||
assert!(matches!(
|
||||
self.shared.transaction.try_lock(),
|
||||
Err(TryLockError::WouldBlock)
|
||||
));
|
||||
self.shared.record("scoped_publish", &entry.path, context);
|
||||
let path = entry.path.clone();
|
||||
let regular = entry.kind() == EntryKind::Regular;
|
||||
let reference = self
|
||||
.with_refs
|
||||
.then(|| format!("published-object-{}", entry.node.id));
|
||||
let mut publication = PublishedObject::new(ScopedObject {
|
||||
inner: Object {
|
||||
entry,
|
||||
custom: self.interfaces[0].reference().clone(),
|
||||
shared: self.shared.clone(),
|
||||
context: context.clone(),
|
||||
},
|
||||
reference,
|
||||
});
|
||||
// Containers expose no interfaces but remain valid fetch subjects.
|
||||
if regular {
|
||||
for interface in &self.interfaces {
|
||||
if interface
|
||||
.reference()
|
||||
.protocol_reference()
|
||||
.validate_for_path(&path)
|
||||
.is_ok()
|
||||
{
|
||||
publication = publication.expose(interface).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(publication)
|
||||
}
|
||||
}
|
||||
struct ScopedPolicy;
|
||||
impl ProjectionPolicy<Entry, ScopedObject> for ScopedPolicy {
|
||||
type Principal = Principal;
|
||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
}
|
||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||
!(matches!(principal, Principal::Limited)
|
||||
&& interface.protocol_reference().scope == "/added")
|
||||
}
|
||||
}
|
||||
type ScopedEngine = Projection<MemoryBackend, ScopedComposition, ScopedPolicy>;
|
||||
fn scoped_engine(shared: Arc<Shared>, with_refs: bool) -> ScopedEngine {
|
||||
configured_engine(shared, with_refs, None)
|
||||
}
|
||||
fn configured_engine(
|
||||
shared: Arc<Shared>,
|
||||
with_refs: bool,
|
||||
interface_validator: Option<Validator>,
|
||||
) -> ScopedEngine {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let mut associations = BTreeMap::new();
|
||||
let mut interfaces = Vec::new();
|
||||
for path in ["/", "/secret", "/secret/nested", "/added"] {
|
||||
let scope = Scope::new(path).unwrap();
|
||||
let validator = if path == "/secret" {
|
||||
interface_validator.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
interfaces.push(
|
||||
registry
|
||||
.register(
|
||||
&scope,
|
||||
"file.read",
|
||||
wip_fs_server::FileReadDefinition,
|
||||
validator,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
associations.insert(path.into(), (shared.node(path), scope));
|
||||
}
|
||||
Projection::new(
|
||||
MemoryBackend::new(shared.clone()),
|
||||
registry,
|
||||
ScopedComposition {
|
||||
shared,
|
||||
associations,
|
||||
interfaces,
|
||||
with_refs,
|
||||
},
|
||||
ScopedPolicy,
|
||||
ResourceLimits::default(),
|
||||
)
|
||||
}
|
||||
fn reference(scope: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "file.read".into(),
|
||||
}
|
||||
}
|
||||
fn request(path: &str, scope: &str, scope_ref: Option<String>) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: reference(scope),
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_observe_fetch_call_with_optional_refs_over_http() {
|
||||
for with_refs in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let limits = ResourceLimits::default();
|
||||
let endpoint = Endpoint::parse("https://example.test/scopes").unwrap();
|
||||
let adapter = HttpAdapter::new(&view, &endpoint, limits);
|
||||
for (path, scope) in [("/secret/nested/data", "/secret"), ("/added", "/added")] {
|
||||
let observe = observe_request(path, 0);
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_observe_request(&endpoint, &observe, limits.wire).unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(observed) =
|
||||
decode_observe_response(&observe, &response, limits.wire).unwrap()
|
||||
else {
|
||||
panic!("observation must succeed");
|
||||
};
|
||||
assert!(observed.object.interfaces.contains(&reference(scope)));
|
||||
let fetch = fetch_request(&reference(scope));
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_fetch_interface_request(&endpoint, &fetch, limits.wire).unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(fetched) =
|
||||
decode_fetch_interface_response(&fetch, &response, limits.wire).unwrap()
|
||||
else {
|
||||
panic!("fetch must succeed");
|
||||
};
|
||||
assert_eq!(
|
||||
fetched.scope_ref,
|
||||
with_refs.then(|| format!("published-object-{}", fixture.shared.node(scope).id))
|
||||
);
|
||||
let mut call = request(path, scope, fetched.scope_ref);
|
||||
call.target.validator = observed.object.validator;
|
||||
let response = adapter.handle_http_with_context(
|
||||
encode_call_operation_request(&endpoint, &call, &fetched.descriptor, limits.wire)
|
||||
.unwrap(),
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
let DecodedResponse::Success(result) =
|
||||
decode_call_operation_response(&call, &fetched.descriptor, &response, limits.wire)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("call must succeed");
|
||||
};
|
||||
assert_eq!(result.result, Value::Bytes(b"hello memory".to_vec()));
|
||||
if !with_refs {
|
||||
call.interface.scope_ref = Some("ref-not-provided-by-publication".into());
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
view.observe_object("/secret")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
view.observe_object("/secret/nested/data")
|
||||
.unwrap()
|
||||
.interfaces,
|
||||
[
|
||||
reference("/"),
|
||||
reference("/secret"),
|
||||
reference("/secret/nested")
|
||||
]
|
||||
);
|
||||
let limited = engine.view(&Principal::Limited);
|
||||
for scope in ["/secret", "/secret/nested", "/added"] {
|
||||
assert_eq!(
|
||||
limited
|
||||
.fetch_interface(&fetch_request(&reference(scope)))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
limited
|
||||
.observe_object("/secret/nested/data")
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
!limited
|
||||
.observe_object("/added")
|
||||
.unwrap()
|
||||
.interfaces
|
||||
.contains(&reference("/added"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_refs_and_object_validator_precedence_use_current_atomic_lineage() {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), true);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let mut call = request("/secret/nested/data", "/secret", Some("stale".into()));
|
||||
call.target.validator = Some(vec![0]);
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::ValidatorMismatch
|
||||
);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
call.interface.scope_ref = view
|
||||
.fetch_interface(&fetch_request(&call.interface.reference))
|
||||
.unwrap()
|
||||
.scope_ref;
|
||||
// Scope state changes do not retire its lifetime; the published ref is not
|
||||
// its validator, and the hook runs on refreshed metadata under the lock.
|
||||
fixture.shared.controls.lock().unwrap().bump_ancestor = Some("/secret".into());
|
||||
fixture.shared.seen.lock().unwrap().clear();
|
||||
view.call_operation(call).unwrap();
|
||||
let seen = fixture.shared.seen.lock().unwrap();
|
||||
let resolves: Vec<_> = seen
|
||||
.iter()
|
||||
.filter(|event| event.stage == "resolve")
|
||||
.map(|event| event.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(resolves, ["/secret/nested/data"]);
|
||||
assert!(
|
||||
seen.iter()
|
||||
.any(|event| event.stage == "scope" && event.path == "/secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonroot_interface_validator_checks_follow_scope_ref_checks() {
|
||||
let fixture = Fixture::new();
|
||||
let engine = configured_engine(fixture.shared.clone(), true, Some(Validator::new(vec![7])));
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let fetched = view
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap();
|
||||
assert_eq!(fetched.validator, Some(vec![7]));
|
||||
let mut call = request("/secret/nested/data", "/secret", Some("stale".into()));
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
call.interface.scope_ref = fetched.scope_ref;
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceValidatorRequired
|
||||
);
|
||||
call.interface.validator = Some(vec![8]);
|
||||
assert_eq!(
|
||||
view.call_operation(call.clone()).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceValidatorMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node(&call.target.path)
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
call.interface.validator = fetched.validator;
|
||||
view.call_operation(call).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_replacement_races_reject_before_dispatch() {
|
||||
for replace in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), false);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let mut call = request("/secret/nested/data", "/secret", None);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
let leaf = fixture.shared.node(&call.target.path);
|
||||
if replace {
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/secret".into());
|
||||
} else {
|
||||
fixture.shared.controls.lock().unwrap().hide_ancestor = Some("/secret".into());
|
||||
}
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::NotFound
|
||||
);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_replacement_and_self_scope_do_not_reuse_registry_lifetimes() {
|
||||
for with_refs in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let engine = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
let view = engine.view(&Principal::Admin);
|
||||
let old_scope = fixture.shared.node("/secret");
|
||||
let old_ref = view
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap()
|
||||
.scope_ref;
|
||||
fixture.shared.nodes.lock().unwrap().remove("/secret");
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
// Reuse even the same id/public ref: refs cannot attest a lifetime.
|
||||
fixture.shared.nodes.lock().unwrap().insert(
|
||||
"/secret".into(),
|
||||
Arc::new(Node {
|
||||
id: old_scope.id,
|
||||
kind: EntryKind::Directory,
|
||||
data: Mutex::new(old_scope.data.lock().unwrap().clone()),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let mut call = request("/secret/nested/data", "/secret", old_ref);
|
||||
call.target.validator = view.observe_object(&call.target.path).unwrap().validator;
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node("/secret/nested/data")
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Fresh registration/engine attests the new publication.
|
||||
let republished = scoped_engine(fixture.shared.clone(), with_refs);
|
||||
republished
|
||||
.view(&Principal::Admin)
|
||||
.fetch_interface(&fetch_request(&reference("/secret")))
|
||||
.unwrap();
|
||||
// Scope == target: ordinary execution pins the old leaf, but cannot
|
||||
// attest it as the current scope after its mapping is replaced.
|
||||
let leaf = fixture.shared.node("/added");
|
||||
let mut call = request("/added", "/added", None);
|
||||
call.target.validator = view.observe_object("/added").unwrap().validator;
|
||||
fixture.shared.controls.lock().unwrap().replace_mapping = true;
|
||||
assert_eq!(
|
||||
view.call_operation(call).unwrap_err().kind(),
|
||||
wip_host::CallErrorKind::InterfaceMismatch
|
||||
);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
view.fetch_interface(&fetch_request(&reference("/added")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,7 @@ publish = false
|
||||
wip-host.workspace = true
|
||||
wip-interface.workspace = true
|
||||
wip-protocol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
wip-client.workspace = true
|
||||
wip-http.workspace = true
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! request-scoped implementation, backend handle, authorization policy, and
|
||||
//! publication wiring remain host-private. A transport binding would translate
|
||||
//! its own wire routes and envelopes to the protocol call DTOs; no HTTP type is
|
||||
//! part of an application trait or interface descriptor.
|
||||
//! part of an application trait or interface descriptor. Its dev-only conformance
|
||||
//! suite also uses the public Client and HTTP crates over real TCP HTTP to test
|
||||
//! scope replacement/deletion, optional refs, principal visibility and precondition
|
||||
//! precedence. Those dependencies are not required by the example executable.
|
||||
//!
|
||||
//! # Breaking migration exercised here
|
||||
//!
|
||||
@@ -229,7 +232,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
|
||||
let published = dispatch_publish(
|
||||
&provider,
|
||||
catalog.reference(),
|
||||
&catalog_response,
|
||||
PublishRequest {
|
||||
title: "Public APIs".into(),
|
||||
body: Some("protocol values in, validated values out".into()),
|
||||
@@ -243,7 +246,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
)?;
|
||||
let rejected = dispatch_publish(
|
||||
&provider,
|
||||
catalog.reference(),
|
||||
&catalog_response,
|
||||
PublishRequest {
|
||||
title: String::new(),
|
||||
body: None,
|
||||
@@ -256,7 +259,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
&provider,
|
||||
request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
@@ -266,7 +270,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
&provider,
|
||||
request(
|
||||
OBJECT_PATH,
|
||||
audit.reference(),
|
||||
&audit_response.interface,
|
||||
audit_response.scope_ref.as_deref(),
|
||||
None,
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
@@ -276,7 +281,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let host_error = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"backend_status",
|
||||
BTreeMap::new(),
|
||||
@@ -288,7 +294,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let unknown_protocol_failure = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"uncertain_backend_status",
|
||||
BTreeMap::new(),
|
||||
@@ -299,7 +306,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let permission_failure = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"restricted_status",
|
||||
BTreeMap::new(),
|
||||
@@ -329,7 +337,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
let interface_mismatch = provider
|
||||
.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
diagnostics.reference(),
|
||||
&diagnostics.reference().protocol_reference(),
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
None,
|
||||
"diagnostics",
|
||||
BTreeMap::new(),
|
||||
@@ -351,7 +360,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||
|
||||
let mut stale_scope = request(
|
||||
OBJECT_PATH,
|
||||
catalog.reference(),
|
||||
&catalog_response.interface,
|
||||
catalog_response.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"status",
|
||||
BTreeMap::new(),
|
||||
@@ -421,13 +431,14 @@ fn ensure_kind(error: &wip_host::CallError, expected: CallErrorKind) -> Result<(
|
||||
|
||||
fn dispatch_publish(
|
||||
provider: &ApplicationProvider,
|
||||
catalog: &InterfaceRef,
|
||||
catalog: &wip_protocol::FetchInterfaceResponse,
|
||||
request_value: PublishRequest,
|
||||
) -> Result<PublishOutcome, Box<dyn Error>> {
|
||||
let encoded_request = request_value.encode()?;
|
||||
let result = provider.call_operation(request(
|
||||
OBJECT_PATH,
|
||||
catalog,
|
||||
&catalog.interface,
|
||||
catalog.scope_ref.as_deref(),
|
||||
Some(CATALOG_VALIDATOR),
|
||||
"publish",
|
||||
BTreeMap::from([("request".into(), encoded_request)]),
|
||||
@@ -445,7 +456,8 @@ fn dispatch_string(
|
||||
|
||||
fn request(
|
||||
path: &str,
|
||||
interface: &InterfaceRef,
|
||||
interface: &InterfaceReference,
|
||||
scope_ref: Option<&str>,
|
||||
interface_validator: Option<&[u8]>,
|
||||
operation: &str,
|
||||
arguments: BTreeMap<String, Value>,
|
||||
@@ -456,8 +468,8 @@ fn request(
|
||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
scope_ref: Some("article-service-instance".into()),
|
||||
reference: interface.protocol_reference(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
reference: interface.clone(),
|
||||
validator: interface_validator.map(<[u8]>::to_vec),
|
||||
},
|
||||
operation: operation.into(),
|
||||
|
||||
@@ -258,6 +258,40 @@ fn assert_schema_neutral(value: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_dtos_keep_identity_binding_and_validators_separate() {
|
||||
// Exact literals/destructuring guard the public Rust contract, including
|
||||
// omission of refs rather than requiring a fabricated identity.
|
||||
let reference = wip_protocol::InterfaceReference {
|
||||
scope: "/article-service".into(),
|
||||
name: "catalog::read#raw".into(),
|
||||
};
|
||||
let request = wip_protocol::FetchInterfaceRequest {
|
||||
interface: reference.clone(),
|
||||
};
|
||||
request.validate().unwrap();
|
||||
let wip_protocol::FetchInterfaceResponse {
|
||||
interface,
|
||||
scope_ref,
|
||||
descriptor,
|
||||
validator,
|
||||
} = wip_protocol::FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: None,
|
||||
descriptor: CatalogDefinition::descriptor(),
|
||||
validator: None,
|
||||
};
|
||||
let target = wip_protocol::InterfaceTarget {
|
||||
reference: interface,
|
||||
scope_ref,
|
||||
validator,
|
||||
};
|
||||
assert_eq!(target.reference, reference);
|
||||
assert!(target.scope_ref.is_none());
|
||||
assert!(target.validator.is_none());
|
||||
assert_eq!(descriptor.format, INTERFACE_FORMAT_V1);
|
||||
}
|
||||
|
||||
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: "/".into(),
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! External-style consumer: a transactional provider, real TCP HTTP, and Client.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use wip_client::{
|
||||
CallOutcome, Client, ClientLimits, Completion, ObservationState, SecurityContext,
|
||||
};
|
||||
use wip_host::{
|
||||
ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject,
|
||||
RegisteredInterface, Scope, ScopeObservation, Validator, WipObject,
|
||||
};
|
||||
use wip_http::{http, *};
|
||||
use wip_interface as wip;
|
||||
use wip_protocol::{InterfaceReference, ObjectObservation, ProtocolErrorCode, ProtocolInteraction};
|
||||
|
||||
#[wip::interface]
|
||||
pub trait Status {
|
||||
fn status(&self) -> wip::OperationResult<String>;
|
||||
fn uncertain(&self) -> wip::OperationResult<String>;
|
||||
}
|
||||
|
||||
struct Service {
|
||||
name: String,
|
||||
reference: Option<String>,
|
||||
validator: Validator,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl WipObject for Service {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn reference(&self) -> Option<&str> {
|
||||
self.reference.as_deref()
|
||||
}
|
||||
fn validator(&self) -> Option<&Validator> {
|
||||
Some(&self.validator)
|
||||
}
|
||||
}
|
||||
impl Status for Service {
|
||||
fn status(&self) -> wip::OperationResult<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.name.clone())
|
||||
}
|
||||
fn uncertain(&self) -> wip::OperationResult<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(wip::HostError::internal(
|
||||
"commit acknowledgement lost",
|
||||
wip::EffectGuarantee::Unknown,
|
||||
))
|
||||
}
|
||||
}
|
||||
struct Publication {
|
||||
scope: Scope,
|
||||
api: RegisteredInterface<StatusDefinition>,
|
||||
reference: Option<String>,
|
||||
}
|
||||
struct State {
|
||||
registry: InterfaceRegistry,
|
||||
root: Publication,
|
||||
service: Option<Publication>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl State {
|
||||
fn new(reference: Option<&str>) -> Self {
|
||||
let mut registry = InterfaceRegistry::new();
|
||||
let scope = Scope::new("/").unwrap();
|
||||
let root = Publication {
|
||||
api: registry
|
||||
.register(&scope, "status", StatusDefinition, None)
|
||||
.unwrap(),
|
||||
scope,
|
||||
reference: None,
|
||||
};
|
||||
let mut state = Self {
|
||||
registry,
|
||||
root,
|
||||
service: None,
|
||||
calls: Arc::default(),
|
||||
};
|
||||
state.republish(reference);
|
||||
state
|
||||
}
|
||||
fn remove(&mut self) {
|
||||
if let Some(old) = self.service.take() {
|
||||
self.registry.remove_scope(&old.scope);
|
||||
}
|
||||
}
|
||||
fn republish(&mut self, reference: Option<&str>) {
|
||||
self.remove();
|
||||
let scope = Scope::new("/service").unwrap();
|
||||
self.service = Some(Publication {
|
||||
api: self
|
||||
.registry
|
||||
.register(
|
||||
&scope,
|
||||
"status",
|
||||
StatusDefinition,
|
||||
Some(Validator::new(b"api-v1")),
|
||||
)
|
||||
.unwrap(),
|
||||
scope,
|
||||
reference: reference.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The HTTP adapter holds the State lock across all provider lookups, preconditions
|
||||
// and dispatch. No separate scope prelookup can race a replacement.
|
||||
struct Provider<'a> {
|
||||
state: &'a State,
|
||||
visible: bool,
|
||||
}
|
||||
impl ObjectProvider for Provider<'_> {
|
||||
type Object = Service;
|
||||
fn with_object<R>(
|
||||
&self,
|
||||
_: &ExecutionContext,
|
||||
path: &str,
|
||||
operation: impl FnOnce(&PublishedObject<Service>) -> R,
|
||||
) -> ProviderResult<R> {
|
||||
let publication = match path {
|
||||
"/" => &self.state.root,
|
||||
"/service" if self.visible => match &self.state.service {
|
||||
Some(value) => value,
|
||||
None => return ProviderResult::NotFound,
|
||||
},
|
||||
_ => return ProviderResult::NotFound,
|
||||
};
|
||||
let object = Service {
|
||||
name: if path == "/" { "" } else { "service" }.into(),
|
||||
reference: publication.reference.clone(),
|
||||
validator: Validator::new(b"object-v1"),
|
||||
calls: self.state.calls.clone(),
|
||||
};
|
||||
let current = ScopeObservation::new(&publication.scope, &object);
|
||||
let mut object = PublishedObject::new(object)
|
||||
.bind_scope(current)
|
||||
.expose(&publication.api)
|
||||
.unwrap();
|
||||
if path == "/service" {
|
||||
let root_object = Service {
|
||||
name: "".into(),
|
||||
reference: None,
|
||||
validator: Validator::new(b"object-v1"),
|
||||
calls: self.state.calls.clone(),
|
||||
};
|
||||
object = object
|
||||
.bind_scope(ScopeObservation::new(&self.state.root.scope, &root_object))
|
||||
.expose(&self.state.root.api)
|
||||
.unwrap();
|
||||
}
|
||||
ProviderResult::Found(operation(&object))
|
||||
}
|
||||
}
|
||||
fn limits() -> Limits {
|
||||
Limits::new(65536, 65536, 64).unwrap()
|
||||
}
|
||||
fn reference(scope: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "status".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(state: &State, path: &str, body: &[u8], visible: bool) -> http::Response<Vec<u8>> {
|
||||
let provider = Provider { state, visible };
|
||||
match path {
|
||||
"/wip/v1/observe" => {
|
||||
let request = decode_observe_request(body, limits()).unwrap();
|
||||
match provider.observe_object(&request.path) {
|
||||
Ok(object) => encode_observe_response(
|
||||
&request,
|
||||
&ObjectObservation {
|
||||
object,
|
||||
children: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&error.protocol_error(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
"/wip/v1/fetch_interface" => {
|
||||
let request = decode_fetch_interface_request(body, limits()).unwrap();
|
||||
match provider.fetch_interface(&state.registry, &request.interface) {
|
||||
Ok(observation) => encode_fetch_interface_response(
|
||||
&request,
|
||||
&observation.protocol_response().unwrap(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::FetchInterface,
|
||||
&error,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
"/wip/v1/call_operation" => {
|
||||
let metadata = decode_call_operation_metadata(body, limits()).unwrap();
|
||||
let decoded = Mutex::new(None);
|
||||
let result = provider.call_operation_with_context_and_decoder(
|
||||
&ExecutionContext::default(),
|
||||
metadata.target.clone(),
|
||||
metadata.interface.clone(),
|
||||
metadata.operation.clone(),
|
||||
|descriptor| {
|
||||
let request = metadata.decode_request(body, descriptor, limits())?;
|
||||
let arguments = request.arguments.clone();
|
||||
*decoded.lock().unwrap() = Some((request, descriptor.clone()));
|
||||
Ok::<_, CodecError>(arguments)
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let (request, descriptor) = decoded.into_inner().unwrap().unwrap();
|
||||
encode_call_operation_response(&request, &descriptor, &result, limits())
|
||||
.unwrap()
|
||||
}
|
||||
Err(error) => encode_protocol_error_response(
|
||||
ProtocolInteraction::CallOperation,
|
||||
&error.protocol_error(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
_ => panic!("unexpected route {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Small bounded test-only HTTP/1.1 listener. Production serving is deliberately
|
||||
// not an example dependency; both directions still cross an actual TCP socket.
|
||||
fn exchange(
|
||||
state: &Arc<Mutex<State>>,
|
||||
request: http::Request<Vec<u8>>,
|
||||
visible: bool,
|
||||
) -> http::Response<Vec<u8>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let state = state.clone();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut raw = Vec::new();
|
||||
let (split, length) = loop {
|
||||
let mut chunk = [0; 1024];
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
raw.extend_from_slice(&chunk[..count]);
|
||||
assert!(raw.len() <= 70000);
|
||||
if let Some(split) = raw.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
let head = std::str::from_utf8(&raw[..split]).unwrap();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
if raw.len() >= split + 4 + length {
|
||||
break (split, length);
|
||||
}
|
||||
}
|
||||
};
|
||||
let path = std::str::from_utf8(&raw[..split])
|
||||
.unwrap()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
let response = handle(
|
||||
&state.lock().unwrap(),
|
||||
path,
|
||||
&raw[split + 4..split + 4 + length],
|
||||
visible,
|
||||
);
|
||||
write!(stream, "HTTP/1.1 {} OK\r\nConnection: close\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n", response.status().as_u16(), response.body().len()).unwrap();
|
||||
stream.write_all(response.body()).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(address).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
write!(stream, "POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", request.uri().path(), request.body().len()).unwrap();
|
||||
stream.write_all(request.body()).unwrap();
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).unwrap();
|
||||
server.join().unwrap();
|
||||
let split = raw.windows(4).position(|part| part == b"\r\n\r\n").unwrap();
|
||||
let status = std::str::from_utf8(&raw[..split])
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.parse::<u16>()
|
||||
.unwrap();
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(raw[split + 4..].to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn setup() -> (Client, wip_client::SessionId) {
|
||||
let mut client = Client::new(ClientLimits::new(1, 16, 16, 16, 16).unwrap(), limits());
|
||||
let session = client
|
||||
.open_session("http://localhost/wip", SecurityContext::new("alice"))
|
||||
.unwrap();
|
||||
(client, session)
|
||||
}
|
||||
fn observe(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
state: &Arc<Mutex<State>>,
|
||||
path: &str,
|
||||
) {
|
||||
let request = client.refresh_object(session, path).unwrap();
|
||||
let response = exchange(state, request.request, true);
|
||||
client.complete(request.id, response).unwrap();
|
||||
}
|
||||
fn fetch(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
state: &Arc<Mutex<State>>,
|
||||
reference: &InterfaceReference,
|
||||
) {
|
||||
let request = client
|
||||
.prepare_interface(session, reference.clone())
|
||||
.unwrap();
|
||||
let body = std::str::from_utf8(request.request.body()).unwrap();
|
||||
assert!(body.contains("\"scope\""));
|
||||
assert!(body.contains("\"name\""));
|
||||
let response = exchange(state, request.request, true);
|
||||
assert!(matches!(
|
||||
client.complete(request.id, response).unwrap(),
|
||||
Completion::Interface(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_http_root_self_same_name_and_optional_scope_refs() {
|
||||
for scope_ref in [Some("instance-a"), None] {
|
||||
let state = Arc::new(Mutex::new(State::new(scope_ref)));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/");
|
||||
let root = client.object(&session, "/").unwrap();
|
||||
assert_eq!(root.object.unwrap().name, "");
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
for scope in ["/", "/service"] {
|
||||
let selected = reference(scope);
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let observation = client.interface(&session, &selected).unwrap().clone();
|
||||
assert_eq!(
|
||||
observation.scope_ref.as_deref(),
|
||||
if scope == "/" { None } else { scope_ref }
|
||||
);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
let logical = decode_call_operation_request(
|
||||
request.request.body(),
|
||||
observation.descriptor.as_ref().unwrap(),
|
||||
limits(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(logical.interface.reference, selected);
|
||||
assert_eq!(logical.interface.scope_ref, observation.scope_ref);
|
||||
let response = exchange(&state, request.request, true);
|
||||
assert!(matches!(
|
||||
client.complete(request.id, response).unwrap(),
|
||||
Completion::Call(record) if matches!(record.outcome, CallOutcome::Success(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_scope_republication_and_ref_less_replacement_require_explicit_calls() {
|
||||
let selected = reference("/service");
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
state.lock().unwrap().republish(Some("instance-b"));
|
||||
let response = exchange(&state, request.request, true);
|
||||
assert_eq!(response.status(), http::StatusCode::CONFLICT);
|
||||
assert!(matches!(client.complete(request.id, response).unwrap(),
|
||||
Completion::Call(record) if matches!(record.outcome,
|
||||
CallOutcome::ProtocolFailure(ref error) if error.code == ProtocolErrorCode::InterfaceMismatch)));
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 0);
|
||||
assert_ne!(
|
||||
client.interface(&session, &selected).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.is_err()
|
||||
);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &selected)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("instance-b")
|
||||
);
|
||||
assert_eq!(
|
||||
state.lock().unwrap().calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"re-observation is not execution"
|
||||
);
|
||||
|
||||
state.lock().unwrap().remove();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
assert!(client.interface(&session, &selected).is_none());
|
||||
state.lock().unwrap().republish(None);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(&session, "/service", &selected, "status", BTreeMap::new())
|
||||
.unwrap();
|
||||
// Ref-less replacement is intentionally not identity-trackable. Current
|
||||
// membership, live publication and both validators still apply at dispatch.
|
||||
state.lock().unwrap().republish(None);
|
||||
let response = exchange(&state, request.request, true);
|
||||
client.complete(request.id, response).unwrap();
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_http_unknown_outcome_is_never_automatically_retried() {
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let (mut client, session) = setup();
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
let selected = reference("/service");
|
||||
fetch(&mut client, &session, &state, &selected);
|
||||
let request = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/service",
|
||||
&selected,
|
||||
"uncertain",
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let response = exchange(&state, request.request, true);
|
||||
let completion = client.complete(request.id, response).unwrap();
|
||||
assert!(
|
||||
matches!(completion, Completion::Call(record) if matches!(record.outcome, CallOutcome::Unknown { .. }))
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
observe(&mut client, &session, &state, "/service");
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_visibility_and_validator_precedence_cross_real_http() {
|
||||
let state = Arc::new(Mutex::new(State::new(Some("instance-a"))));
|
||||
let endpoint = Endpoint::parse("http://localhost/wip").unwrap();
|
||||
let selected = reference("/service");
|
||||
let fetch = wip_protocol::FetchInterfaceRequest {
|
||||
interface: selected.clone(),
|
||||
};
|
||||
let response = exchange(
|
||||
&state,
|
||||
encode_fetch_interface_request(&endpoint, &fetch, limits()).unwrap(),
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_fetch_interface_response(&fetch, &response, limits()).unwrap(), DecodedResponse::ProtocolFailure { error, .. } if error.code == ProtocolErrorCode::InterfaceNotFound)
|
||||
);
|
||||
let descriptor = StatusDefinition::descriptor();
|
||||
let mut call = wip_protocol::CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: "/service".into(),
|
||||
validator: Some(b"stale-object".to_vec()),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: selected,
|
||||
scope_ref: Some("stale-scope".into()),
|
||||
validator: Some(b"stale-api".to_vec()),
|
||||
},
|
||||
operation: "status".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
for (visible, object_validator, scope_ref, interface_validator, expected) in [
|
||||
(
|
||||
false,
|
||||
b"stale-object".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::NotFound,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"stale-object".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
Some("stale-scope"),
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::InterfaceMismatch,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
Some("instance-a"),
|
||||
None,
|
||||
ProtocolErrorCode::InterfaceValidatorRequired,
|
||||
),
|
||||
(
|
||||
true,
|
||||
b"object-v1".as_slice(),
|
||||
None,
|
||||
Some(b"stale-api".as_slice()),
|
||||
ProtocolErrorCode::InterfaceValidatorMismatch,
|
||||
),
|
||||
] {
|
||||
call.target.validator = Some(object_validator.to_vec());
|
||||
call.interface.scope_ref = scope_ref.map(str::to_owned);
|
||||
call.interface.validator = interface_validator.map(<[u8]>::to_vec);
|
||||
let response = exchange(
|
||||
&state,
|
||||
encode_call_operation_request(&endpoint, &call, &descriptor, limits()).unwrap(),
|
||||
visible,
|
||||
);
|
||||
assert!(
|
||||
matches!(decode_call_operation_response(&call, &descriptor, &response, limits()).unwrap(), DecodedResponse::ProtocolFailure { error, .. } if error.code == expected)
|
||||
);
|
||||
}
|
||||
assert_eq!(state.lock().unwrap().calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
@@ -91,10 +91,29 @@ these labels. The output has no extra outer signature string quoting.
|
||||
Keep selection, cache and calls keyed by structured references and operation
|
||||
names, never display text. No parser, scope inference, identity reconstruction,
|
||||
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||
ancestry, authorization and freshness remain runtime concerns. Screen integration
|
||||
is follow-up T-23; the current bounded screen/value/diagnostic rendering is
|
||||
unchanged. At integration, apply existing UI layout bounds to display labels
|
||||
without feeding shortened labels back into identity or calls.
|
||||
ancestry, authorization and freshness remain runtime concerns. Normal Entry,
|
||||
Interface/Operation, editor, confirmation and operation-history screens use these
|
||||
qualified labels with existing bounded layout. Shortened labels never feed back
|
||||
into identity or calls.
|
||||
|
||||
### Rust API and wire migration
|
||||
|
||||
Interface identities in the presentation map, action/selection rows, in-flight
|
||||
subjects and editors are now `InterfaceReference { scope, name }`, not strings.
|
||||
This is a breaking Rust API change; consumers must construct an explicit canonical
|
||||
absolute scope rather than parse a label or infer a scope from a target Entry.
|
||||
`ArgumentEditor` also retains its Entry `path`; `new` defaults to `/`, and
|
||||
`for_target(path)` fixes the chosen target before editing. Normal UI selection
|
||||
sets it automatically. Confirmation never retargets an existing editor.
|
||||
`App::operation_selection` retains the Entry and structured row through temporary
|
||||
loading gaps; the numeric cursor is only a layout position. Retaining this subject
|
||||
does not expose stale operations or authorize execution.
|
||||
|
||||
The TUI uses the scoped Protocol/HTTP Client wire: Interface references are
|
||||
structured, and calls use an Interface target carrying the reference and optional
|
||||
`scope_ref`/validator preconditions. The optional identity is copied by the Client,
|
||||
not invented by the UI; no additional required ref is introduced. This wire is not
|
||||
compatible with the former string-reference request/response shape.
|
||||
|
||||
## Keyboard bindings
|
||||
|
||||
@@ -128,10 +147,12 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||
presented as current.
|
||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
||||
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
||||
Structured references remain intact in selection, acquisition, and call state;
|
||||
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
||||
- **Interfaces / Operations** identifies every operation by structured `(scope,
|
||||
interface name, operation name)`, displaying `scope::interface#operation` with
|
||||
explicit absolute scope and safely quoted components. Structured references
|
||||
remain intact in selection, acquisition, and call state; display labels are
|
||||
never parsed back into references. Descriptor lookahead follows fresh visible
|
||||
Objects, shares the
|
||||
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||
status, and result types.
|
||||
@@ -158,6 +179,22 @@ Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
|
||||
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
||||
the runtime's authoritative transition.
|
||||
|
||||
Every Client transition is mirrored into retained UI Object, tree and Interface
|
||||
state, including scope retirement and cache removal. An open editor/confirmation
|
||||
is bound to private Object/Interface snapshots (not displayed refs/validators).
|
||||
Changed, stale, loading, removed or retargeted observations retire the editor;
|
||||
reobservation never recreates or executes it. Descriptor rows remain identified
|
||||
by the structured reference when another Interface gains or loses operation rows.
|
||||
Explicitly select/load an Interface header to refresh a retained stale descriptor,
|
||||
then select an operation and confirm a new call. Client calls already dispatched
|
||||
retain their original immutable context and are never moved to a new scope.
|
||||
|
||||
Without refs, a same-path replacement with indistinguishable public metadata
|
||||
cannot always be detected; the UI does not synthesize identity or infer deletion
|
||||
from missing tree edges. Optional refs remain optional. Typed `Entry` results are
|
||||
only observed when individually selected; the UI neither bulk-fetches result
|
||||
paths nor adds guessed parent/indexable edges.
|
||||
|
||||
For an unknown outcome, the UI states that execution or side effects may have
|
||||
occurred, marks the related Entry stale, and tells the user to refresh. Refreshing
|
||||
does not recreate the call: the user must select the operation, review fresh input,
|
||||
|
||||
@@ -280,6 +280,9 @@ pub struct App {
|
||||
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||
/// Selected row in the flattened interface/operation view.
|
||||
pub operation_cursor: usize,
|
||||
/// Retained Entry and structured row while current operation rows are hidden.
|
||||
/// This is selection identity only, never authority to execute a stale row.
|
||||
pub operation_selection: Option<(String, OperationRow)>,
|
||||
/// Selected recent-history row.
|
||||
pub history_cursor: usize,
|
||||
/// Active overlay.
|
||||
@@ -310,6 +313,7 @@ impl App {
|
||||
focus: Focus::Tree,
|
||||
interfaces: BTreeMap::new(),
|
||||
operation_cursor: 0,
|
||||
operation_selection: None,
|
||||
history_cursor: 0,
|
||||
modal: Modal::None,
|
||||
in_flight: BTreeMap::new(),
|
||||
@@ -362,7 +366,7 @@ impl App {
|
||||
}
|
||||
InputKey::Enter | InputKey::Character('e') => {
|
||||
let action = Action::Execute {
|
||||
path: self.selected_path.clone(),
|
||||
path: editor.path.clone(),
|
||||
interface: editor.interface.clone(),
|
||||
operation: editor.operation.name.clone(),
|
||||
arguments,
|
||||
@@ -464,17 +468,21 @@ impl App {
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
if let Some(operation_name) = &row.operation {
|
||||
if let Some(interface) = self.interfaces.get(&row.interface)
|
||||
&& interface.state == ViewState::Fresh
|
||||
&& let Some(descriptor) = &interface.descriptor
|
||||
&& let Some(operation) = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|operation| operation.name == *operation_name)
|
||||
{
|
||||
self.modal = Modal::Editor(ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
));
|
||||
self.modal = Modal::Editor(
|
||||
ArgumentEditor::new(
|
||||
row.interface.clone(),
|
||||
descriptor.clone(),
|
||||
operation.clone(),
|
||||
)
|
||||
.for_target(self.selected_path.clone()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return vec![Action::FetchInterface(row.interface.clone())];
|
||||
@@ -515,6 +523,10 @@ impl App {
|
||||
|
||||
/// Records a pending request separately by interaction type.
|
||||
pub fn request_started(&mut self, id: u64, kind: InFlightKind) {
|
||||
// Object/Interface loading can remove all rows before completion. Keep
|
||||
// the exact subject now rather than recovering identity from a cursor.
|
||||
let selected = self.operation_rows().get(self.operation_cursor).cloned();
|
||||
self.restore_operation_selection(selected);
|
||||
match &kind {
|
||||
InFlightKind::Object(path) => {
|
||||
self.ensure_entry(path).state = ViewState::Loading(id);
|
||||
@@ -683,6 +695,37 @@ impl App {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Preserves a selected structured subject across descriptor/row movement.
|
||||
pub(crate) fn restore_operation_selection(&mut self, selected: Option<OperationRow>) {
|
||||
if let Some(selected) = selected {
|
||||
self.operation_selection = Some((self.selected_path.clone(), selected));
|
||||
} else if self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.is_some_and(|(path, _)| path != &self.selected_path)
|
||||
{
|
||||
self.operation_selection = None;
|
||||
}
|
||||
let rows = self.operation_rows();
|
||||
self.operation_cursor = self
|
||||
.operation_selection
|
||||
.as_ref()
|
||||
.and_then(|(_, selected)| {
|
||||
rows.iter().position(|row| row == selected).or_else(|| {
|
||||
rows.iter().position(|row| {
|
||||
row.interface == selected.interface && row.operation.is_none()
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
// Keep identity through a temporarily empty (loading/stale) view. Once
|
||||
// rows are current, the retained identity follows the actual selection,
|
||||
// falling back to its own Interface header if the Operation was retired.
|
||||
if let Some(row) = rows.get(self.operation_cursor) {
|
||||
self.operation_selection = Some((self.selected_path.clone(), row.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_entry(&mut self, path: &str) -> &mut EntryView {
|
||||
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
|
||||
let candidate = self
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct InvalidArguments;
|
||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArgumentEditor {
|
||||
/// Exact Entry target selected when editing began; never read from display text.
|
||||
pub path: String,
|
||||
/// Structured interface reference selected for the call.
|
||||
pub interface: InterfaceReference,
|
||||
/// Selected operation declaration.
|
||||
@@ -68,6 +70,7 @@ impl ArgumentEditor {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
path: "/".into(),
|
||||
interface,
|
||||
operation,
|
||||
descriptor,
|
||||
@@ -76,6 +79,13 @@ impl ArgumentEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixes the Entry target for the editor and subsequent confirmation.
|
||||
#[must_use]
|
||||
pub fn for_target(mut self, path: impl Into<String>) -> Self {
|
||||
self.path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Moves to the preceding field without wrapping.
|
||||
pub fn previous_field(&mut self) {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
//! Presentation and terminal interaction for stateful WIP client sessions.
|
||||
//!
|
||||
//! Interface identity is a structured `wip_protocol::InterfaceReference` with an
|
||||
//! explicit absolute scope. Display-only [`mod@reference`] labels safely quote
|
||||
//! `scope::interface#operation`; shortened labels are never parsed for calls.
|
||||
//! This breaks the former string-reference Rust API and follows the scoped HTTP
|
||||
//! wire format. Ref and validator preconditions remain private Client metadata.
|
||||
//! Editors retain their selected Entry target and are retired when the Client's
|
||||
//! Object/Interface snapshots change; reobservation never executes an operation.
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
|
||||
+118
-18
@@ -11,11 +11,22 @@ use crate::editor::type_name;
|
||||
|
||||
const MAX_RENDER_TEXT: usize = 512;
|
||||
|
||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
format!(
|
||||
"scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
/// Bounded presentation only; invalid references are never repaired or inferred.
|
||||
pub(crate) fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_interface_reference(reference)
|
||||
.unwrap_or_else(|_| "(invalid interface reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The exact structured subject remains separate from this shortened label.
|
||||
pub(crate) fn operation_label(
|
||||
reference: &wip_protocol::InterfaceReference,
|
||||
operation: &str,
|
||||
) -> String {
|
||||
sanitize(
|
||||
&crate::reference::format_qualified_operation(reference, operation)
|
||||
.unwrap_or_else(|_| "(invalid operation reference)".into()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +243,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.find(|operation| operation.name == *name)
|
||||
});
|
||||
let signature = declaration.map_or_else(
|
||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
||||
|| format!(" {}", operation_label(&row.interface, name)),
|
||||
|operation| {
|
||||
let parameters = operation
|
||||
.parameters
|
||||
@@ -253,9 +264,8 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
||||
.map(|documentation| format!(" — {}", documentation.summary))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" ({}, {}) ({parameters}) -> {}{}",
|
||||
interface_label(&row.interface),
|
||||
name,
|
||||
" {} ({parameters}) -> {}{}",
|
||||
operation_label(&row.interface, name),
|
||||
type_name(&operation.returns.r#type),
|
||||
documentation
|
||||
)
|
||||
@@ -361,10 +371,9 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
let area = centered(frame.area(), 86, 78);
|
||||
frame.render_widget(Clear, area);
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"Target: {} Operation: ({}, {})",
|
||||
sanitize(&app.selected_path),
|
||||
interface_label(&editor.interface),
|
||||
sanitize(&editor.operation.name)
|
||||
"Target: {} Operation: {}",
|
||||
sanitize(&editor.path),
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
))];
|
||||
if editor.fields.is_empty() {
|
||||
lines.push(Line::from("(no parameters)"));
|
||||
@@ -412,9 +421,12 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
|
||||
Line::from(format!("Entry: {}", sanitize(&editor.path))),
|
||||
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
|
||||
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
|
||||
Line::from(format!(
|
||||
"Operation: {}",
|
||||
operation_label(&editor.interface, &editor.operation.name)
|
||||
)),
|
||||
Line::from("Input values:"),
|
||||
];
|
||||
for (name, value) in arguments {
|
||||
@@ -726,7 +738,7 @@ mod tests {
|
||||
}
|
||||
let snapshot = rendered(&app, 140, 40);
|
||||
assert!(snapshot.contains("public root description"));
|
||||
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
|
||||
assert!(snapshot.contains("/::demo-interface#run"));
|
||||
assert!(snapshot.contains("SUCCESS"));
|
||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||
@@ -822,7 +834,7 @@ mod tests {
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = app.operation_rows().len() - 1;
|
||||
let snapshot = rendered(&app, 100, 30);
|
||||
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
|
||||
assert!(snapshot.contains("/::many#operation-19"));
|
||||
|
||||
for index in 0..20 {
|
||||
app.push_history(HistoryItem {
|
||||
@@ -850,13 +862,101 @@ mod tests {
|
||||
scope: "/nested".into(),
|
||||
name: "#::操作".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
||||
assert_eq!(interface_label(&reference), "/nested::\"#::操作\"");
|
||||
assert_ne!(
|
||||
interface_label(&reference),
|
||||
interface_label(&root_reference(&reference.name))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_labels_are_safely_quoted_on_normal_screens_and_modals() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
let local = wip_protocol::InterfaceReference {
|
||||
scope: "/a::b".into(),
|
||||
name: "same#name".into(),
|
||||
};
|
||||
let root = root_reference(&local.name);
|
||||
let operation = "run#\n\u{202e}";
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: operation.into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
};
|
||||
app.apply_object(ObjectObservation {
|
||||
path: "/".into(),
|
||||
object: Some(Object {
|
||||
name: String::new(),
|
||||
description: None,
|
||||
interfaces: vec![root.clone(), local.clone()],
|
||||
r#ref: Some("hidden-object-ref".into()),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
}),
|
||||
validator: Some(b"hidden-object-validator".to_vec()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
for reference in [&root, &local] {
|
||||
app.apply_interface(InterfaceObservation {
|
||||
reference: reference.clone(),
|
||||
descriptor: Some(descriptor.clone()),
|
||||
validator: Some(b"hidden-interface-validator".to_vec()),
|
||||
scope_ref: Some("hidden-scope-ref".into()),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
}
|
||||
let root_label = "/::\"same#name\"";
|
||||
let local_label = "\"/a::b\"::\"same#name\"";
|
||||
let operation_label = "\"/a::b\"::\"same#name\"#\"run#\\n\\u202e\"";
|
||||
let normal = rendered(&app, 220, 55);
|
||||
assert!(normal.contains(root_label));
|
||||
assert!(normal.contains(local_label));
|
||||
assert!(normal.contains(operation_label));
|
||||
app.focus = Focus::Operations;
|
||||
app.operation_cursor = 3;
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let editor = rendered(&app, 220, 55);
|
||||
assert!(editor.contains(operation_label));
|
||||
assert!(editor.contains("Argument editor"));
|
||||
app.reduce(crate::app::InputKey::Enter);
|
||||
let confirmation = rendered(&app, 220, 55);
|
||||
assert!(confirmation.contains(operation_label));
|
||||
assert!(confirmation.contains("EXPLICIT EXECUTION CONFIRMATION"));
|
||||
for snapshot in [normal, editor, confirmation] {
|
||||
assert!(!snapshot.contains("hidden-"));
|
||||
assert!(!snapshot.contains('\u{202e}'));
|
||||
}
|
||||
assert!(matches!(
|
||||
app.reduce(crate::app::InputKey::Enter).as_slice(),
|
||||
[crate::app::Action::Execute { interface, operation: name, .. }]
|
||||
if interface == &local && name == operation
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortened_and_invalid_labels_never_infer_a_reference() {
|
||||
let reference = root_reference(&"x".repeat(1000));
|
||||
assert!(interface_label(&reference).ends_with('…'));
|
||||
assert_eq!(reference.name.len(), 1000);
|
||||
let invalid = wip_protocol::InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "name".into(),
|
||||
};
|
||||
assert_eq!(interface_label(&invalid), "(invalid interface reference)");
|
||||
assert_eq!(
|
||||
operation_label(&invalid, "run"),
|
||||
"(invalid operation reference)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_large_bytes_are_bounded() {
|
||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
||||
|
||||
+136
-25
@@ -21,7 +21,7 @@ use crate::app::{
|
||||
Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal,
|
||||
};
|
||||
use crate::config::{Config, ConfigError};
|
||||
use crate::render::{bounded_value, draw, sanitize};
|
||||
use crate::render::{bounded_value, draw, interface_label, operation_label, sanitize};
|
||||
use crate::terminal::{TerminalGuard, translate_key};
|
||||
|
||||
/// Stable transport failure category.
|
||||
@@ -205,6 +205,12 @@ pub struct Runtime<T: Transport> {
|
||||
sender: mpsc::Sender<WorkerEvent>,
|
||||
receiver: mpsc::Receiver<WorkerEvent>,
|
||||
ignored: BTreeSet<u64>,
|
||||
// Private Client snapshots bind an open editor to the observed lifetime,
|
||||
// including optional refs/validators that must never appear on normal screens.
|
||||
editor_context: Option<(
|
||||
wip_client::ObjectObservation,
|
||||
wip_client::InterfaceObservation,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl<T: Transport> Runtime<T> {
|
||||
@@ -231,6 +237,7 @@ impl<T: Transport> Runtime<T> {
|
||||
sender,
|
||||
receiver,
|
||||
ignored: BTreeSet::new(),
|
||||
editor_context: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,7 +263,15 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
/// Applies a key through the reducer and starts any resulting effects.
|
||||
pub fn handle_key(&mut self, key: InputKey) -> bool {
|
||||
let was_editing = matches!(self.app.modal, Modal::Editor(_) | Modal::Confirm { .. });
|
||||
self.reconcile_authoritative();
|
||||
if was_editing && matches!(self.app.modal, Modal::None) {
|
||||
// Do not reinterpret a stale confirmation keystroke as selection
|
||||
// of another row/scope after the old editor has been retired.
|
||||
return false;
|
||||
}
|
||||
let actions = self.app.reduce(key);
|
||||
self.capture_editor_context();
|
||||
let mut quit = false;
|
||||
for action in actions {
|
||||
quit |= self.perform(action);
|
||||
@@ -387,9 +402,14 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let id = request.id;
|
||||
self.app.request_started(id.get(), kind.clone());
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
let prepared = match self.transport.prepare(request.request) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
@@ -511,6 +531,11 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn finish(&mut self, event: WorkerEvent) {
|
||||
let selected = self
|
||||
.app
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
let kind = self.app.in_flight.get(&event.id.get()).cloned();
|
||||
if kind.is_none() {
|
||||
return;
|
||||
@@ -549,7 +574,7 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reconcile_authoritative();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
self.reconcile_visible_lookahead();
|
||||
}
|
||||
|
||||
@@ -580,9 +605,15 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
Completion::ProtocolFailure(error) => {
|
||||
let subject = match kind {
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!(" for {}", interface_label(reference))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
kind: HistoryKind::DomainFailure,
|
||||
title: format!("Host {:?}", error.code),
|
||||
title: format!("Host {:?}{subject}", error.code),
|
||||
detail: sanitize(&error.message),
|
||||
entry_target: None,
|
||||
});
|
||||
@@ -605,11 +636,12 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||
let key = format!(
|
||||
"(scope={}, name={}, {}) on {}",
|
||||
sanitize(&record.context.interface().reference.scope),
|
||||
sanitize(&record.context.interface().reference.name),
|
||||
record.context.operation().name,
|
||||
record.context.object().path
|
||||
"{} on {}",
|
||||
operation_label(
|
||||
&record.context.interface().reference,
|
||||
&record.context.operation().name
|
||||
),
|
||||
sanitize(&record.context.object().path)
|
||||
);
|
||||
self.app.status = match &record.outcome {
|
||||
CallOutcome::Success(_) => "operation completed".into(),
|
||||
@@ -690,12 +722,14 @@ impl<T: Transport> Runtime<T> {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!(
|
||||
"interface scope={}, name={}",
|
||||
sanitize(&reference.scope),
|
||||
sanitize(&reference.name)
|
||||
),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
Some(InFlightKind::Interface(reference)) => {
|
||||
format!("interface {}", interface_label(reference))
|
||||
}
|
||||
Some(InFlightKind::Operation {
|
||||
interface,
|
||||
operation,
|
||||
..
|
||||
}) => operation_label(interface, operation),
|
||||
None => "request".into(),
|
||||
};
|
||||
self.app.push_history(HistoryItem {
|
||||
@@ -752,16 +786,87 @@ impl<T: Transport> Runtime<T> {
|
||||
}
|
||||
|
||||
fn reconcile_authoritative(&mut self) {
|
||||
self.sync_all_objects();
|
||||
let evicted_interfaces = self
|
||||
let selected = self
|
||||
.app
|
||||
.interfaces
|
||||
.keys()
|
||||
.filter(|reference| self.client.interface(&self.session, reference).is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for reference in evicted_interfaces {
|
||||
self.app.interfaces.remove(&reference);
|
||||
.operation_rows()
|
||||
.get(self.app.operation_cursor)
|
||||
.cloned();
|
||||
self.reconcile_authoritative_with_selection(selected);
|
||||
}
|
||||
|
||||
fn reconcile_authoritative_with_selection(
|
||||
&mut self,
|
||||
selected: Option<crate::app::OperationRow>,
|
||||
) {
|
||||
self.sync_all_objects();
|
||||
let references = self.app.interfaces.keys().cloned().collect::<Vec<_>>();
|
||||
for reference in references {
|
||||
match self.client.interface(&self.session, &reference).cloned() {
|
||||
Some(observation) => self.app.apply_interface(observation),
|
||||
None => {
|
||||
self.app.interfaces.remove(&reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
let paths = self.app.entries.keys().cloned().collect::<Vec<_>>();
|
||||
for path in paths {
|
||||
if let Some(tree) = self.client.tree(&self.session, &path).cloned() {
|
||||
self.app.apply_tree(tree);
|
||||
} else if let Some(entry) = self.app.entries.get_mut(&path)
|
||||
&& !matches!(entry.tree_state, crate::app::ViewState::Unloaded)
|
||||
{
|
||||
entry.tree_state = crate::app::ViewState::Stale;
|
||||
entry.children.clear();
|
||||
}
|
||||
}
|
||||
self.app.restore_operation_selection(selected);
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn capture_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.editor_context.is_none() {
|
||||
self.editor_context = self.client.object(&self.session, &editor.path).zip(
|
||||
self.client
|
||||
.interface(&self.session, &editor.interface)
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
self.reconcile_editor_context();
|
||||
}
|
||||
|
||||
fn reconcile_editor_context(&mut self) {
|
||||
let editor = match &self.app.modal {
|
||||
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
|
||||
_ => {
|
||||
self.editor_context = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let valid = self
|
||||
.editor_context
|
||||
.as_ref()
|
||||
.is_some_and(|(object, interface)| {
|
||||
object.path == editor.path
|
||||
&& self.app.selected_path == editor.path
|
||||
&& interface.reference == editor.interface
|
||||
&& interface.descriptor.as_ref() == Some(&editor.descriptor)
|
||||
&& object.state == wip_client::ObservationState::Fresh
|
||||
&& interface.state == wip_client::ObservationState::Fresh
|
||||
&& self.client.object(&self.session, &editor.path).as_ref() == Some(object)
|
||||
&& self.client.interface(&self.session, &editor.interface) == Some(interface)
|
||||
});
|
||||
if !valid {
|
||||
self.app.modal = Modal::None;
|
||||
self.editor_context = None;
|
||||
self.app.status =
|
||||
"editor retired: target or interface changed; reobserve and select again".into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +876,9 @@ impl<T: Transport> Runtime<T> {
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
entry.object.is_some() || self.client.object(&self.session, &entry.path).is_some()
|
||||
entry.object.is_some()
|
||||
|| entry.state != crate::app::ViewState::Unloaded
|
||||
|| self.client.object(&self.session, &entry.path).is_some()
|
||||
})
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -930,6 +1037,10 @@ impl Display for RuntimeError {
|
||||
|
||||
impl Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runtime_scoped_tests.rs"]
|
||||
mod scoped_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! UI lifetime regressions driven by real Client/HTTP codec completions.
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use wip_client::ObservationState;
|
||||
use wip_http::{
|
||||
decode_call_operation_request, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceReference, Object,
|
||||
ObjectObservation, ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ReturnDeclaration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingTransport(Arc<Mutex<Vec<Request<Vec<u8>>>>>);
|
||||
|
||||
impl Transport for RecordingTransport {
|
||||
type Prepared = Request<Vec<u8>>;
|
||||
|
||||
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
|
||||
self.0.lock().unwrap().push(request);
|
||||
Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "deliberate pre-dispatch failure".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute(&self, _: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||
panic!("test transport must never dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(scope: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "same#::名前".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor() -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run#::\n\u{202e}".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Unit,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime<RecordingTransport> {
|
||||
let config = Config::parse(["https://example.test/wip"], None, None, |_| None).unwrap();
|
||||
Runtime::new(&config, RecordingTransport::default()).unwrap()
|
||||
}
|
||||
|
||||
fn observe(runtime: &mut Runtime<RecordingTransport>, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
let response = encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: "/item".into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: Object {
|
||||
name: "item".into(),
|
||||
description: None,
|
||||
interfaces: vec![reference("/"), reference("/item")],
|
||||
r#ref: identity.map(str::to_owned),
|
||||
validator: None,
|
||||
},
|
||||
children: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
// Mirror the actual dispatch-time loading transition before the response.
|
||||
runtime.reconcile_authoritative();
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(response),
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch(runtime: &mut Runtime<RecordingTransport>, scope: &str, identity: Option<&str>) {
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference(scope))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Interface(reference(scope)));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response(scope, identity)),
|
||||
});
|
||||
}
|
||||
|
||||
fn interface_response(scope: &str, identity: Option<&str>) -> Response<Vec<u8>> {
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference(scope),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference(scope),
|
||||
scope_ref: identity.map(str::to_owned),
|
||||
descriptor: descriptor(),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn populated() -> Runtime<RecordingTransport> {
|
||||
let mut runtime = runtime();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
fetch(&mut runtime, "/", Some("root-scope"));
|
||||
fetch(&mut runtime, "/item", Some("old-scope"));
|
||||
runtime.app.navigate_entry("/item");
|
||||
runtime.app.focus = crate::app::Focus::Operations;
|
||||
runtime.app.operation_cursor = 3;
|
||||
runtime
|
||||
}
|
||||
|
||||
fn confirm_local(runtime: &mut Runtime<RecordingTransport>) {
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(&runtime.app.modal, Modal::Editor(editor)
|
||||
if editor.interface == reference("/item") && editor.path == "/item"));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::Confirm { .. }));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_names_dispatch_exact_structured_scope_only_after_confirmation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.target.path, "/item");
|
||||
assert_eq!(call.interface.reference, reference("/item"));
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert_eq!(call.operation, descriptor().operations[0].name);
|
||||
assert!(
|
||||
runtime.app.history[0]
|
||||
.title
|
||||
.contains("/item::\"same#::名前\"#")
|
||||
);
|
||||
assert!(runtime.app.history[0].title.contains("\\n\\u202e"));
|
||||
assert!(!runtime.app.history[0].title.contains("old-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_loading_preserves_scoped_operation_selection_until_reobservation() {
|
||||
let mut runtime = populated();
|
||||
observe(&mut runtime, Some("old-scope"));
|
||||
let selected = &runtime.app.operation_rows()[runtime.app.operation_cursor];
|
||||
assert_eq!(selected.interface, reference("/item"));
|
||||
assert_eq!(
|
||||
selected.operation.as_deref(),
|
||||
Some(descriptor().operations[0].name.as_str())
|
||||
);
|
||||
assert!(matches!(&runtime.app.operation_selection, Some((path, row))
|
||||
if path == "/item" && row == selected));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_replacement_retires_confirmation_and_requires_explicit_reobservation() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let local = &runtime.app.interfaces[&reference("/item")];
|
||||
assert_eq!(local.state, crate::app::ViewState::Stale);
|
||||
assert!(local.descriptor.is_none());
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.app.status.contains("editor retired"));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.client
|
||||
.ensure_interface(&runtime.session, reference("/item"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// Refetch without any execution; selecting and confirming again is required.
|
||||
fetch(&mut runtime, "/item", Some("new-scope"));
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref.as_deref(), Some("new-scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_old_fetch_cannot_revive_retired_editor_or_descriptor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime.app.request_started(
|
||||
request.id.get(),
|
||||
InFlightKind::Interface(reference("/item")),
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(interface_response("/item", Some("old-scope"))),
|
||||
});
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_ne!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Fresh
|
||||
);
|
||||
assert!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.is_none()
|
||||
);
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_deletion_and_republication_remove_ui_cache_without_switching_to_root() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
let request = runtime
|
||||
.client
|
||||
.refresh_object(&runtime.session, "/item")
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: request.id,
|
||||
response: Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::NotFound,
|
||||
message: "scope deleted".into(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert!(!runtime.app.interfaces.contains_key(&reference("/item")));
|
||||
assert!(runtime.app.interfaces.contains_key(&reference("/")));
|
||||
assert!(runtime.app.operation_rows().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
observe(&mut runtime, Some("republished-scope"));
|
||||
fetch(&mut runtime, "/item", Some("republished-scope"));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_ref_and_retained_stale_descriptor_are_mirrored_without_synthetic_binding() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
observe(&mut runtime, None);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")].state,
|
||||
crate::app::ViewState::Stale
|
||||
);
|
||||
fetch(&mut runtime, "/item", None);
|
||||
runtime.app.operation_cursor = 3;
|
||||
confirm_local(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
let call =
|
||||
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
assert_eq!(call.interface.scope_ref, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_mirrors_retained_state_and_preserves_selected_reference_as_rows_shift() {
|
||||
let mut runtime = populated();
|
||||
// Fetch of the first Interface temporarily removes its Operation row. The
|
||||
// selected operation on the second Interface must stay selected by identity.
|
||||
let request = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/"))
|
||||
.unwrap();
|
||||
runtime.reconcile_authoritative();
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
assert!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor]
|
||||
.operation
|
||||
.is_some()
|
||||
);
|
||||
let error = runtime
|
||||
.client
|
||||
.fail_transport(request.id, "test failure")
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||
runtime.reconcile_authoritative();
|
||||
assert!(matches!(
|
||||
runtime.app.interfaces[&reference("/")].state,
|
||||
crate::app::ViewState::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
|
||||
reference("/item")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_dispatched_call_keeps_original_context_across_scope_republication() {
|
||||
let mut runtime = populated();
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor().operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor(), default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: logical.operation.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
observe(&mut runtime, Some("new-scope"));
|
||||
let mut new_descriptor = descriptor();
|
||||
new_descriptor.operations[0].returns.r#type = TypeExpr::String;
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
runtime
|
||||
.app
|
||||
.request_started(fetch.id.get(), InFlightKind::Interface(reference("/item")));
|
||||
runtime.finish(WorkerEvent {
|
||||
id: fetch.id,
|
||||
response: Ok(encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("new-scope".into()),
|
||||
validator: None,
|
||||
descriptor: new_descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor(),
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Unit,
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(runtime.app.history[0].kind, HistoryKind::Success);
|
||||
assert_eq!(runtime.app.history[0].detail, "null");
|
||||
assert_eq!(
|
||||
runtime.app.interfaces[&reference("/item")]
|
||||
.descriptor
|
||||
.as_ref(),
|
||||
Some(&new_descriptor)
|
||||
);
|
||||
assert_eq!(logical.interface.scope_ref.as_deref(), Some("old-scope"));
|
||||
assert!(
|
||||
runtime.transport.0.lock().unwrap().is_empty(),
|
||||
"no redispatch or retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_entry_results_are_only_observed_on_selection_without_guessed_edges() {
|
||||
let mut runtime = populated();
|
||||
// The original no-validator descriptor is immutable within its scope
|
||||
// lifetime. Republish the scope before testing a different result schema.
|
||||
observe(&mut runtime, Some("entry-result-scope"));
|
||||
let mut descriptor = descriptor();
|
||||
descriptor.operations[0].returns.r#type = TypeExpr::Record {
|
||||
fields: vec![
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "entries".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Entry),
|
||||
},
|
||||
},
|
||||
wip_protocol::FieldDeclaration {
|
||||
name: "ordinary".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
],
|
||||
};
|
||||
let fetch = runtime
|
||||
.client
|
||||
.prepare_interface(&runtime.session, reference("/item"))
|
||||
.unwrap();
|
||||
let completion = runtime
|
||||
.client
|
||||
.complete(
|
||||
fetch.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: reference("/item"),
|
||||
},
|
||||
&FetchInterfaceResponse {
|
||||
interface: reference("/item"),
|
||||
scope_ref: Some("entry-result-scope".into()),
|
||||
validator: None,
|
||||
descriptor: descriptor.clone(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.apply_completion(completion, None);
|
||||
let call = runtime
|
||||
.client
|
||||
.prepare_call(
|
||||
&runtime.session,
|
||||
"/item",
|
||||
&reference("/item"),
|
||||
&descriptor.operations[0].name,
|
||||
std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let logical =
|
||||
decode_call_operation_request(call.request.body(), &descriptor, default_wire_limits())
|
||||
.unwrap();
|
||||
runtime.client.mark_dispatched(call.id).unwrap();
|
||||
runtime.app.request_started(
|
||||
call.id.get(),
|
||||
InFlightKind::Operation {
|
||||
path: "/item".into(),
|
||||
interface: reference("/item"),
|
||||
operation: descriptor.operations[0].name.clone(),
|
||||
dispatched: true,
|
||||
},
|
||||
);
|
||||
runtime.finish(WorkerEvent {
|
||||
id: call.id,
|
||||
response: Ok(wip_http::encode_call_operation_response(
|
||||
&logical,
|
||||
&descriptor,
|
||||
&wip_protocol::CallOperationResponse {
|
||||
result: Value::Record(std::collections::BTreeMap::from([
|
||||
(
|
||||
"entries".into(),
|
||||
Value::List(vec![
|
||||
Value::String("/hidden/a".into()),
|
||||
Value::String("/hidden/b".into()),
|
||||
]),
|
||||
),
|
||||
("ordinary".into(), Value::String("/not-an-entry".into())),
|
||||
])),
|
||||
validator: None,
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.unwrap()),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime
|
||||
.app
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| item.entry_target.is_some())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/b"));
|
||||
assert!(!runtime.app.entries.contains_key("/not-an-entry"));
|
||||
assert!(runtime.app.entries["/"].children.is_empty());
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
runtime.app.focus = crate::app::Focus::History;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
let requests = runtime.transport.0.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"only the selected typed Entry is observed"
|
||||
);
|
||||
let observe =
|
||||
wip_http::decode_observe_request(requests[0].body(), default_wire_limits()).unwrap();
|
||||
assert_eq!(observe.path, "/hidden/b");
|
||||
assert_eq!(observe.depth, 0);
|
||||
assert!(
|
||||
!runtime.app.entries.contains_key("/hidden"),
|
||||
"no inferred indexable parent"
|
||||
);
|
||||
assert!(!runtime.app.entries.contains_key("/hidden/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_change_during_confirmation_never_retargets_the_old_editor() {
|
||||
let mut runtime = populated();
|
||||
confirm_local(&mut runtime);
|
||||
runtime.app.selected_path = "/".into();
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app.modal, Modal::None));
|
||||
assert!(runtime.transport.0.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
runtime
|
||||
.client
|
||||
.interface(&runtime.session, &reference("/item"))
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user