fix: distinguish Drive service and typed entry results

This commit is contained in:
2026-10-29 05:05:20 +09:00
3 changed files with 1643 additions and 36 deletions
@@ -31,6 +31,8 @@ pub(crate) mod delegation;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub mod wip; pub mod wip;
#[cfg(test)]
mod wip_tests;
use crate::{ use crate::{
feature::{ feature::{
+178 -36
View File
@@ -5,6 +5,26 @@
//! decimal node ID, preventing substitution with another Workspace or ID. Previously acquired //! decimal node ID, preventing substitution with another Workspace or ID. Previously acquired
//! Client entries are not added to Tree either. //! Client entries are not added to Tree either.
//! //!
//! The service has Workspace identity `drive-service:<workspace>`; folders/files retain
//! `drive:<workspace>:<node>`. Both have self-scoped Interfaces, but the service is not a folder
//! alias: its search and receipt inquiry span the Workspace. Revocation withdraws publication;
//! restoring a mount requires fresh observations. v2 replaces the former all-JSON descriptor.
//! Old service scope refs and v1 memberships are rejected, never rebound to a new descriptor.
//!
//! Native results declare navigation as Entry, separately from opaque Backend metadata. IDs,
//! parent IDs, names, URLs and commit IDs inside metadata are data, not resolvable WIP references.
//! Metadata remains Json because it is a Backend snapshot, not an additional navigation API.
//! For example, invoke service `root`, inspect the returned `path`, invoke that folder's `list`,
//! then inspect only the chosen `entries[i].path` and invoke `read`. A create/save receipt's
//! `entry.path` is used the same way; a returned folder path is also a `relocate.parent` input.
//! Cursors and absent receipt entries are omitted, not encoded as nullable Entry values.
//! Common file mutations retain their shared opaque commit metadata contract: they already
//! target the known file, and do not promise navigation through that metadata's legacy Tool data.
//!
//! Projection is local and never acquires result entries. Later Inspect failures cannot turn a
//! committed create/save into a failed mutation to be resent. `request_status` projects the
//! original receipt (including its optional live entry), without performing another mutation.
//!
//! The provider resolves one entry under current Backend authorization and creates a self-scoped //! The provider resolves one entry under current Backend authorization and creates a self-scoped
//! contract for its kind. Metadata, descriptor, validator, and handler are fixed to one publication; //! contract for its kind. Metadata, descriptor, validator, and handler are fixed to one publication;
//! the route binds the entry/creation parent. Move destinations accept typed Entry paths. //! the route binds the entry/creation parent. Move destinations accept typed Entry paths.
@@ -26,12 +46,13 @@ use serde_json::json;
use server_api::{DriveEntry, DriveEntryKind, DriveEntryRef}; use server_api::{DriveEntry, DriveEntryKind, DriveEntryRef};
use std::{collections::BTreeMap, sync::Arc}; use std::{collections::BTreeMap, sync::Arc};
use wip_protocol::{ use wip_protocol::{
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, Documentation, EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ReturnDeclaration, TypeDeclaration, TypeExpr, Value,
}; };
const ROOT: &str = "/drive"; const ROOT: &str = "/drive";
const INTERFACE: &str = "yoi.drive/entry/v1"; const INTERFACE: &str = "yoi.drive/entry/v2";
pub fn mount_drive_wip( pub fn mount_drive_wip(
registry: &mut WipMountRegistry, registry: &mut WipMountRegistry,
@@ -158,15 +179,20 @@ impl WipSubtreeProvider for DriveProvider {
} else { } else {
entry.entry.node_id.clone() entry.entry.node_id.clone()
}, },
description: Some(format!( description: Some(if root {
"Workspace Drive {:?} {:?}; stable ID {}, bodies only through bounded operations", "Workspace Drive service; root discovery, bounded Workspace search and request receipts. Not a folder; use root's returned Entry as a destination.".into()
entry.kind, entry.name, entry.entry.node_id } else {
)), format!(
"Workspace Drive {:?} {:?}; stable ID {}, bodies only through bounded operations",
entry.kind, entry.name, entry.entry.node_id
)
}),
interfaces: vec![reference.clone()], interfaces: vec![reference.clone()],
r#ref: Some(format!( r#ref: Some(if root {
"drive:{}:{}", format!("drive-service:{}", entry.entry.workspace_id)
entry.entry.workspace_id, entry.entry.node_id } else {
)), format!("drive:{}:{}", entry.entry.workspace_id, entry.entry.node_id)
}),
validator: Some(validator), validator: Some(validator),
}; };
let common_read = entry.kind == DriveEntryKind::File; let common_read = entry.kind == DriveEntryKind::File;
@@ -295,19 +321,144 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
r#type, r#type,
} }
} }
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { FieldDeclaration {
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], name: name.into(),
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], required,
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)], documentation: None,
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)], r#type,
"create_folder"=>vec![parameter("name",true,TypeExpr::String)], }
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
"save_workdir"=>vec![parameter("target_workdir",true,TypeExpr::String),parameter("path",true,TypeExpr::String),parameter("name",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
"request_status"=>vec![parameter("request_id",true,TypeExpr::String)],
_=>vec![],
},returns:ReturnDeclaration {documentation:None,r#type:TypeExpr::Json}}).collect()}
} }
fn record(fields: Vec<FieldDeclaration>) -> TypeExpr {
TypeExpr::Record { fields }
}
fn entry_result_type() -> TypeExpr {
TypeExpr::Named {
name: "DriveEntryResult".into(),
}
}
fn receipt_type(entry_required: bool) -> TypeExpr {
record(vec![
field("request_id", true, TypeExpr::String),
field("entry", entry_required, entry_result_type()),
])
}
fn result_type(operation: &str) -> TypeExpr {
match operation {
"root" | "metadata" => entry_result_type(),
"list" | "search" => record(vec![
field(
"entries",
true,
TypeExpr::List {
items: Box::new(entry_result_type()),
},
),
field("next_after", false, TypeExpr::String),
]),
"create_folder" | "create_text" | "save_workdir" | "relocate" => receipt_type(true),
"delete" => record(vec![field("request_id", true, TypeExpr::String)]),
"request_status" => record(vec![
field("request_id", true, TypeExpr::String),
field(
"state",
true,
TypeExpr::Enum {
cases: ["uncommitted", "committed"]
.into_iter()
.map(|name| EnumCase {
name: name.into(),
documentation: None,
})
.collect(),
},
),
field("response", false, receipt_type(false)),
]),
"read" => record(vec![
field("entry", true, entry_result_type()),
field("text", true, TypeExpr::String),
field("truncated", true, TypeExpr::Boolean),
]),
"view_image" => record(vec![
field("entry", true, entry_result_type()),
field("attached", true, TypeExpr::Boolean),
field("mime_type", true, TypeExpr::String),
field("bytes", true, TypeExpr::Integer),
]),
_ => unreachable!("Drive operation has no native result contract"),
}
}
fn descriptor(specs: &[Spec], root: bool) -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: Some(Documentation {
summary: if root { "Workspace Drive service; root discovery, paged search and original-request receipts" } else { "Workspace-bound Drive entry; operations apply to this stable folder/file ID" }.into(),
details: Some("Entry paths are typed navigation, not authority or mutation observations. Metadata is an opaque Backend snapshot: its IDs, parent, URL and names are data, not WIP references. Inspect only selected paths; list/search never expand Tree. Optional next_after/response/entry are omitted when absent; uncommitted does not prove failure. The Host/Client retain committed-request observations; do not supply validators or retry conflicts/unknown mutations. request_status queries the original receipt. Images attach to durable ToolOutput. Workdir source read and Drive destination write remain independent.".into()),
}),
types: vec![TypeDeclaration {
name: "DriveEntryResult".into(),
documentation: Some(Documentation { summary: "Stable navigable Entry and ordinary Backend metadata; only path is a WIP reference".into(), details: None }),
definition: record(vec![field("path", true, TypeExpr::Entry), field("metadata", true, TypeExpr::Json)]),
}],
operations: specs.iter().filter(|spec| !matches!(spec.operation, "edit" | "write")).map(|spec| OperationDeclaration {
name: spec.operation.into(),
documentation: Some(Documentation { summary: spec.description.into(), details: None }),
parameters: match spec.operation {
"list" => vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"search" => vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"read" => vec![parameter("max_bytes",false,TypeExpr::Integer)],
"create_text" => vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
"create_folder" => vec![parameter("name",true,TypeExpr::String)],
"relocate" => vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
"save_workdir" => vec![parameter("target_workdir",true,TypeExpr::String),parameter("path",true,TypeExpr::String),parameter("name",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
"request_status" => vec![parameter("request_id",true,TypeExpr::String)],
_ => vec![],
},
returns: ReturnDeclaration { documentation: None, r#type: result_type(spec.operation) },
}).collect(),
}
}
/// Local projection only: no follow-up acquisition may obscure a successful mutation.
fn result_value(operation: &str, mut value: serde_json::Value) -> Result<Value, WipOperationError> {
match operation {
"list" | "search" => {
if value["next_after"].is_null() {
value.as_object_mut().unwrap().remove("next_after");
}
}
"delete" => {
value.as_object_mut().unwrap().remove("entry");
}
"request_status" => {
if value["response"].is_null() {
value.as_object_mut().unwrap().remove("response");
} else {
let response = value["response"].as_object_mut().unwrap();
let entry = response.remove("entry").unwrap_or(serde_json::Value::Null);
if !entry.is_null() {
// Status uses the Backend receipt shape, unlike normal Tool mutation results.
let entry: DriveEntry = serde_json::from_value(entry).map_err(|_| {
WipOperationError::OutcomeUnknown(
"Drive receipt entry unavailable; retain the original request ID"
.into(),
)
})?;
response.insert("entry".into(), super::entry_value(entry));
}
}
}
_ => {}
}
json_to_wip(&value).map_err(|_| {
WipOperationError::OutcomeUnknown(
"Completed Drive result unavailable; query request_status before another mutation"
.into(),
)
})
}
struct DriveHandler { struct DriveHandler {
feature: DriveFeature, feature: DriveFeature,
observation_nonce: Arc<str>, observation_nonce: Arc<str>,
@@ -394,13 +545,9 @@ impl WipOperationHandler for DriveHandler {
))); )));
} }
}; };
let parent = if path == ROOT { let parent = parse_entry(&self.feature, path)
self.feature.backend.root().await.map_err(map_error)?.entry .map_err(map_error)?
} else { .ok_or_else(|| map_error(DriveError::Denied))?;
parse_entry(&self.feature, path)
.map_err(map_error)?
.ok_or_else(|| map_error(DriveError::Denied))?
};
fields.insert("parent".into(), json!(parent)); fields.insert("parent".into(), json!(parent));
fields.insert("entry".into(), json!(self.entry.entry)); fields.insert("entry".into(), json!(self.entry.entry));
} }
@@ -440,12 +587,7 @@ impl WipOperationHandler for DriveHandler {
), ),
)); ));
} }
let value = json_to_wip(&output.value).map_err(|_| { let value = result_value(operation, output.value.clone())?;
WipOperationError::OutcomeUnknown(
"Completed Drive result unavailable; query request_status before another mutation"
.into(),
)
})?;
// Attachments use the existing ToolOutput side channel and durable Engine // Attachments use the existing ToolOutput side channel and durable Engine
// capture, not transient request injection or URL-only success. // capture, not transient request injection or URL-only success.
let result = if matches!(operation, "write" | "edit" | "relocate") { let result = if matches!(operation, "write" | "edit" | "relocate") {
File diff suppressed because it is too large Load Diff