Compare commits

...
Author SHA1 Message Date
Hare d58809bdbc docs: describe scoped consumer API and wire migration
Document FS lifetime attestation and explicit names, structured TUI identity versus qualified display, optional scope refs and real HTTP conformance coverage. Acceptance suite passes including workspace docs with -D warnings.
2026-10-08 04:03:51 +09:00
Hare 84cb5904df feat(tui): reconcile scoped selection and publication lifetimes
Reuse canonical escaped absolute reference labels; retain structured operation selection across loading and row movement. Freeze editor targets and retire confirmation on Client observation invalidation. Mirror retained stale/loading Object, tree and Interface state without ref fabrication or automatic execution.

Validation: 36 TUI unit tests, real filesystem HTTP workflow, 8 formatter and 6 asynchronous runtime tests, and 2 doc-tests pass. Full workspace fmt/check/all-target tests/doc-tests, Nix clippy -D warnings, rustdoc -D warnings, public example and diff check pass. Scoped regressions cover same-name dispatch, stale/ref-less/replaced/deleted scopes, old in-flight contexts and selective typed-entry observation.
2026-10-08 04:03:51 +09:00
Hare fc52d72a01 test(public-api): exercise scoped lifetimes across real HTTP
Construct example calls from fetched structured identity and scope_ref. Add exhaustive public DTO coverage and a transactional external-style provider over TCP to exercise root/self scopes, equal names in distinct scopes, optional and stale refs, deletion/republication, visibility, validator precedence and non-retry of unknown outcomes.

Validation: cargo test -p wip-public-api-example (9 DTO/example tests, 4 real TCP conformance tests); Nix clippy --all-targets -D warnings.
2026-10-08 03:56:24 +09:00
Hare a7186173b4 feat(fs): bind external scopes to current execution lineage
Add fail-closed Composition lifetime attestation for non-root scopes; preserve pinned deployment-root semantics and principal visibility. Fetch and dispatch bind optional published scope refs inside the refreshed backend boundary.

Validation: cargo test -p wip-fs-server (25 unit, 8 HTTP, 31 extension tests and 1 doc-test); Nix clippy for FS and public example with -D warnings; git diff --check.
2026-10-08 03:56:17 +09:00
21 changed files with 2222 additions and 145 deletions
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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
+34 -7
View File
@@ -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
+3 -1
View File
@@ -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,
+113 -42
View File
@@ -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,
+5 -4
View File
@@ -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.
//!
+8 -1
View File
@@ -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
);
}
}
+4
View File
@@ -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
+27 -15
View File
@@ -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);
}
+45 -8
View File
@@ -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,
+49 -6
View File
@@ -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
+10
View File
@@ -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);
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+567
View File
@@ -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
);
}