feat: migrate protocol and HTTP to structured interface references

T-20: identify interfaces by exact scope/name pairs; add optional opaque scope_ref metadata and separate shape validation from path-dependent scope compatibility. Preserve call error precedence, strict JSON, validators and transport boundaries. Mechanically migrate consumers and document API/wire compatibility and deferred lifecycle semantics.

Validation: cargo fmt --all --check; git diff --check; cargo check --workspace --all-targets; cargo test -p wip-protocol -p wip-http; cargo test --workspace (221 passing tests including doctests); cargo test --workspace --all-targets; nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps. All pass. cargo/rustc 1.95.0; development-shell Clippy/rustc 1.97.1.
This commit is contained in:
2026-10-07 18:29:51 +09:00
parent bf5fa54f44
commit 8ecc307fc9
34 changed files with 2124 additions and 263 deletions
+4 -2
View File
@@ -96,8 +96,10 @@ 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 reference,
operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
- **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
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.
+71 -16
View File
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation};
use wip_protocol::{InterfaceDescriptor, Object, Value};
use wip_protocol::{InterfaceDescriptor, InterfaceReference, Object, Value};
use crate::editor::ArgumentEditor;
@@ -139,8 +139,8 @@ impl EntryView {
/// Cached interface descriptor state used by presentation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceView {
/// Opaque interface reference.
pub reference: String,
/// Structured interface reference.
pub reference: InterfaceReference,
/// Descriptor when accepted by the client runtime.
pub descriptor: Option<InterfaceDescriptor>,
/// Descriptor observation state.
@@ -187,13 +187,13 @@ pub enum InFlightKind {
/// Tree expansion or refresh.
Tree(String),
/// Descriptor fetch.
Interface(String),
Interface(InterfaceReference),
/// Explicit operation call.
Operation {
/// Target path.
path: String,
/// Interface reference.
interface: String,
interface: InterfaceReference,
/// Operation name.
operation: String,
/// Whether the transport dispatch boundary was crossed.
@@ -241,13 +241,13 @@ pub enum Action {
path: String,
},
/// Retrieve one interface descriptor.
FetchInterface(String),
FetchInterface(InterfaceReference),
/// Execute only after explicit confirmation.
Execute {
/// Entry target.
path: String,
/// Interface reference.
interface: String,
interface: InterfaceReference,
/// Operation name.
operation: String,
/// Descriptor-validated arguments.
@@ -276,8 +276,8 @@ pub struct App {
pub selected_path: String,
/// Focused pane.
pub focus: Focus,
/// Interface descriptor views by opaque reference.
pub interfaces: BTreeMap<String, InterfaceView>,
/// Interface descriptor views by structured reference.
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
/// Selected row in the flattened interface/operation view.
pub operation_cursor: usize,
/// Selected recent-history row.
@@ -615,7 +615,7 @@ impl App {
/// Returns interface references advertised by fresh visible Objects in display
/// and per-Object declaration order, with duplicates removed.
#[must_use]
pub fn visible_interface_references(&self) -> Vec<String> {
pub fn visible_interface_references(&self) -> Vec<InterfaceReference> {
let mut references = Vec::new();
let mut seen = BTreeSet::new();
for path in self.visible_paths() {
@@ -716,7 +716,7 @@ impl App {
.or_insert_with(|| EntryView::placeholder(path))
}
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
fn ensure_interface(&mut self, reference: &InterfaceReference) -> &mut InterfaceView {
if !self.interfaces.contains_key(reference)
&& self.interfaces.len() >= MAX_PRESENTATION_INTERFACES
{
@@ -757,7 +757,7 @@ impl App {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationRow {
/// Interface reference, which participates in operation identity.
pub interface: String,
pub interface: InterfaceReference,
/// Operation name, or `None` for the interface heading.
pub operation: Option<String>,
}
@@ -773,6 +773,12 @@ pub fn parent_path(path: &str) -> String {
#[cfg(test)]
mod tests {
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
wip_protocol::InterfaceReference {
scope: "/".into(),
name: name.into(),
}
}
use super::*;
use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr};
@@ -780,7 +786,10 @@ mod tests {
Object {
name: name.into(),
description: Some(format!("{name} description")),
interfaces: interfaces.iter().map(|value| (*value).into()).collect(),
interfaces: interfaces
.iter()
.map(|value| root_reference(value))
.collect(),
r#ref: None,
validator: None,
}
@@ -846,7 +855,51 @@ mod tests {
assert_eq!(
app.visible_interface_references(),
["root", "shared", "child"]
[
root_reference("root"),
root_reference("shared"),
root_reference("child")
]
);
}
#[test]
fn presentation_retains_equal_names_in_distinct_scopes() {
let mut app = App::new("https://example.test/wip");
let root = root_reference("shared#::操作");
let local = InterfaceReference {
scope: "/a".into(),
name: root.name.clone(),
};
let mut value = object("a", &[]);
value.interfaces = vec![root.clone(), local.clone()];
app.navigate_entry("/a");
app.apply_object(ObjectObservation {
path: "/a".into(),
object: Some(value),
validator: None,
state: ObservationState::Fresh,
});
for reference in [&root, &local] {
app.apply_interface(InterfaceObservation {
reference: reference.clone(),
descriptor: None,
validator: None,
scope_ref: None,
state: ObservationState::Fresh,
});
}
assert_eq!(app.interfaces.len(), 2);
assert_eq!(
app.visible_interface_references(),
vec![root.clone(), local.clone()]
);
assert_eq!(
app.operation_rows()
.iter()
.map(|row| row.interface.clone())
.collect::<Vec<_>>(),
vec![root, local]
);
}
@@ -874,7 +927,8 @@ mod tests {
}],
};
app.apply_interface(InterfaceObservation {
reference: "iface".into(),
scope_ref: None,
reference: root_reference("iface"),
descriptor: Some(descriptor),
validator: None,
state: ObservationState::Fresh,
@@ -903,7 +957,8 @@ mod tests {
for index in 0..(MAX_PRESENTATION_INTERFACES + 50) {
app.apply_interface(InterfaceObservation {
reference: format!("interface-{index}"),
scope_ref: None,
reference: root_reference(&format!("interface-{index}")),
descriptor: None,
validator: None,
state: ObservationState::Fresh,
+15 -7
View File
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value};
use wip_protocol::{
InterfaceDescriptor, InterfaceReference, OperationDeclaration, TypeExpr, Value,
};
const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024;
@@ -30,8 +32,8 @@ pub struct InvalidArguments;
/// Pure, terminal-independent editor built from an operation descriptor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArgumentEditor {
/// Opaque interface reference selected for the call.
pub interface: String,
/// Structured interface reference selected for the call.
pub interface: InterfaceReference,
/// Selected operation declaration.
pub operation: OperationDeclaration,
/// Complete descriptor used to resolve named types.
@@ -46,7 +48,7 @@ impl ArgumentEditor {
/// Builds an editor whose fields preserve descriptor order.
#[must_use]
pub fn new(
interface: impl Into<String>,
interface: InterfaceReference,
descriptor: InterfaceDescriptor,
operation: OperationDeclaration,
) -> Self {
@@ -66,7 +68,7 @@ impl ArgumentEditor {
})
.collect();
Self {
interface: interface.into(),
interface,
operation,
descriptor,
fields,
@@ -466,6 +468,12 @@ fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result<Value, Strin
#[cfg(test)]
mod tests {
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
wip_protocol::InterfaceReference {
scope: "/".into(),
name: name.into(),
}
}
use super::*;
use wip_protocol::{
EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration,
@@ -560,7 +568,7 @@ mod tests {
}],
);
let operation = descriptor.operations[0].clone();
let mut editor = ArgumentEditor::new("test", descriptor, operation);
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
let inputs = [
"null",
"true",
@@ -595,7 +603,7 @@ mod tests {
vec![],
);
let operation = descriptor.operations[0].clone();
let mut editor = ArgumentEditor::new("test", descriptor, operation);
let mut editor = ArgumentEditor::new(root_reference("test"), descriptor, operation);
editor.fields[1].input = "not-a-bool".into();
assert_eq!(editor.validate(), Err(InvalidArguments));
assert!(
+42 -13
View File
@@ -11,6 +11,14 @@ 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)
)
}
/// Draws the complete application without mutating it.
pub fn draw(frame: &mut Frame<'_>, app: &App) {
let area = frame.area();
@@ -162,7 +170,7 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
object
.interfaces
.iter()
.map(|value| sanitize(value))
.map(interface_label)
.collect::<Vec<_>>()
.join(", ")
}
@@ -224,7 +232,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
.find(|operation| operation.name == *name)
});
let signature = declaration.map_or_else(
|| format!(" ({}, {})", row.interface, name),
|| format!(" ({}, {})", interface_label(&row.interface), name),
|operation| {
let parameters = operation
.parameters
@@ -246,7 +254,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
.unwrap_or_default();
format!(
" ({}, {}) ({parameters}) -> {}{}",
row.interface,
interface_label(&row.interface),
name,
type_name(&operation.returns.r#type),
documentation
@@ -266,7 +274,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
(
Line::from(format!(
"Interface {} [{}]{}",
sanitize(&row.interface),
interface_label(&row.interface),
state,
documentation
)),
@@ -355,7 +363,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
let mut lines = vec![Line::from(format!(
"Target: {} Operation: ({}, {})",
sanitize(&app.selected_path),
sanitize(&editor.interface),
interface_label(&editor.interface),
sanitize(&editor.operation.name)
))];
if editor.fields.is_empty() {
@@ -405,7 +413,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
.add_modifier(Modifier::BOLD),
),
Line::from(format!("Entry: {}", sanitize(&app.selected_path))),
Line::from(format!("Interface: {}", sanitize(&editor.interface))),
Line::from(format!("Interface: {}", interface_label(&editor.interface))),
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))),
Line::from("Input values:"),
];
@@ -579,6 +587,12 @@ fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String {
#[cfg(test)]
mod tests {
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
wip_protocol::InterfaceReference {
scope: "/".into(),
name: name.into(),
}
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -664,7 +678,7 @@ mod tests {
object: Some(Object {
name: String::new(),
description: Some("public root description".into()),
interfaces: vec!["demo-interface".into()],
interfaces: vec![root_reference("demo-interface")],
r#ref: None,
validator: Some(b"must-not-render".to_vec()),
}),
@@ -691,7 +705,8 @@ mod tests {
}],
};
app.apply_interface(InterfaceObservation {
reference: "demo-interface".into(),
scope_ref: None,
reference: root_reference("demo-interface"),
descriptor: Some(descriptor.clone()),
validator: Some(b"must-not-render".to_vec()),
state: ObservationState::Fresh,
@@ -711,7 +726,7 @@ mod tests {
}
let snapshot = rendered(&app, 140, 40);
assert!(snapshot.contains("public root description"));
assert!(snapshot.contains("(demo-interface, run)"));
assert!(snapshot.contains("(scope=/, name=demo-interface, run)"));
assert!(snapshot.contains("SUCCESS"));
assert!(snapshot.contains("PROTOCOL ERROR"));
assert!(snapshot.contains("TRANSPORT FAILURE"));
@@ -719,7 +734,7 @@ mod tests {
assert!(!snapshot.contains("must-not-render"));
let mut editor = ArgumentEditor::new(
"demo-interface",
root_reference("demo-interface"),
descriptor.clone(),
descriptor.operations[0].clone(),
);
@@ -774,7 +789,7 @@ mod tests {
object: Some(Object {
name: String::new(),
description: None,
interfaces: vec!["many".into()],
interfaces: vec![root_reference("many")],
r#ref: None,
validator: None,
}),
@@ -793,7 +808,8 @@ mod tests {
})
.collect::<Vec<_>>();
app.apply_interface(InterfaceObservation {
reference: "many".into(),
scope_ref: None,
reference: root_reference("many"),
descriptor: Some(InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
@@ -806,7 +822,7 @@ mod tests {
app.focus = Focus::Operations;
app.operation_cursor = app.operation_rows().len() - 1;
let snapshot = rendered(&app, 100, 30);
assert!(snapshot.contains("(many, operation-19)"));
assert!(snapshot.contains("(scope=/, name=many, operation-19)"));
for index in 0..20 {
app.push_history(HistoryItem {
@@ -828,6 +844,19 @@ mod tests {
assert!(snapshot.contains("too small"));
}
#[test]
fn interface_labels_show_scope_and_name_without_parsing() {
let reference = wip_protocol::InterfaceReference {
scope: "/nested".into(),
name: "#::操作".into(),
};
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
assert_ne!(
interface_label(&reference),
interface_label(&root_reference(&reference.name))
);
}
#[test]
fn control_characters_and_large_bytes_are_bounded() {
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
+16 -5
View File
@@ -303,7 +303,10 @@ impl<T: Transport> Runtime<T> {
Err(error) => self.local_error("subtree refresh", error),
},
Action::FetchInterface(reference) => {
match self.client.prepare_interface(&self.session, &reference) {
match self
.client
.prepare_interface(&self.session, reference.clone())
{
Ok(request) => {
self.dispatch(request, InFlightKind::Interface(reference), false)
}
@@ -367,7 +370,10 @@ impl<T: Transport> Runtime<T> {
}
}
for reference in references {
match self.client.ensure_interface(&self.session, &reference) {
match self
.client
.ensure_interface(&self.session, reference.clone())
{
Ok(Some(request)) => {
self.dispatch(request, InFlightKind::Interface(reference), false);
}
@@ -599,8 +605,9 @@ impl<T: Transport> Runtime<T> {
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
let key = format!(
"({}, {}) on {}",
record.context.interface().reference,
"(scope={}, name={}, {}) on {}",
sanitize(&record.context.interface().reference.scope),
sanitize(&record.context.interface().reference.name),
record.context.operation().name,
record.context.object().path
);
@@ -683,7 +690,11 @@ 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 {reference}"),
Some(InFlightKind::Interface(reference)) => format!(
"interface scope={}, name={}",
sanitize(&reference.scope),
sanitize(&reference.name)
),
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
None => "request".into(),
};