fix: publish typed workdir result entries and current content entrances

This commit is contained in:
2026-10-28 17:35:14 +09:00
parent a1fbe1e754
commit 9b5fad3970
4 changed files with 1034 additions and 45 deletions
@@ -1,5 +1,16 @@
//! Host-owned Repository, Workdir and Worker attachment Objects.
//! Inventory is resolved from Workspace authority on demand, not a second catalog.
//!
//! Result Records distinguish Entry coordinates from identity/selector strings. An Entry's wire
//! value is a string; only its declared type makes it navigable. Provider metadata (capabilities,
//! nullable selectors, occupancy and removal outcomes) stays JSON, not a hidden reference catalog.
//! A Workdir source's config mapping is not a live entrance: only a current Attachment publishes one.
//!
//! Coordinates never acquire destinations or grant content access. Inspect/Invoke recheck current
//! provider authority and observation validators, including connection generations. Post-attach
//! lookup is separate from mutation success; rediscover unavailable references, never repeat attach.
mod result;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -78,6 +89,7 @@ struct Provider {
catalog: bool,
manage: bool,
config_content: bool,
checkout_content: bool,
}
/// Register Object ownership once, then contribute independent management operations.
@@ -94,9 +106,9 @@ pub fn mount_workspace_workdir_wip(
permissions,
catalog,
manage,
checkout_content: registry.has_subtree("/checkouts"),
config_content: registry
.routes()
.any(|route| route == crate::feature::builtin::workspace_config::CONTENT_ROOT),
.has_subtree(crate::feature::builtin::workspace_config::CONTENT_ROOT),
};
for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] {
let namespace =
@@ -421,46 +433,100 @@ impl Provider {
if let Some(id) = raw.get("working_directory_id").and_then(Json::as_str) {
result.insert("workdir_path".into(), json!(item_path(Domain::Workdir, id)));
}
// Content references are resolved from current Backend authority,
// never inferred from the alias or an OS session. An old lifetime
// cannot refer to a replacement connection at the same root.
if self.config_content
&& raw.get("alias").and_then(Json::as_str)
== Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS)
&& let Some(current) =
self.content_reference(raw, &mut result)?;
}
result.insert("path".into(), json!(item_path(domain, id)));
Ok(Json::Object(result))
}
fn content_reference(
&self,
raw: &Json,
result: &mut serde_json::Map<String, Json>,
) -> Result<(), WipOperationError> {
let alias = raw.get("alias").and_then(Json::as_str);
let id = raw.get("connection_id").and_then(Json::as_str);
let workdir = raw.get("working_directory_id").and_then(Json::as_str);
let state = if alias == Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS) {
if !self.config_content {
"unpublished"
} else if let Some(current) =
crate::feature::builtin::workspace_config::backend::WorkspaceConfigBackend::new(
self.backend.client.clone(),
)
.current()?
&& current.connection_id == id
&& raw.get("working_directory_id").and_then(Json::as_str)
== Some(current.working_directory_id.as_str())
&& raw.get("alias").and_then(Json::as_str) == Some(current.alias.as_str())
&& Some(current.connection_id.as_str()) == id
&& Some(current.working_directory_id.as_str()) == workdir
&& Some(current.alias.as_str()) == alias
{
result.insert("content_path".into(), json!(current.content_path));
result.insert("name".into(), json!(current.name));
result.insert("purpose".into(), json!(current.purpose));
result.insert("access".into(), json!(current.access));
"available"
} else {
"disconnected"
}
if !result.contains_key("content_path")
&& let Some(alias) = raw.get("alias").and_then(Json::as_str)
} else if !self.checkout_content {
"unpublished"
} else if let Some(alias) = alias
&& let Ok(selected) = self.backend.session_router.resolve(Some(alias))
&& raw.get("working_directory_id").and_then(Json::as_str)
== Some(selected.session.workdir().id().as_str())
&& selected
&& workdir == Some(selected.session.workdir().id().as_str())
{
if selected
.session
.capabilities()
.supports(workdir::WorkdirSessionCapability::Read)
{
// Alias and Workdir identity alone do not identify a connection lifetime.
// Refuse an entrance when the Backend no longer recognizes this exact link.
if !self
.backend
.client
.authorize_workdir_attachment_operation("workdir.attachment.read", alias, id)
.is_ok_and(|current| Some(current.as_str()) == id)
{
result.insert("content_state".into(), json!("unavailable"));
return Ok(());
}
result.insert(
"checkout_path".into(),
json!(crate::checkout::checkout_root(alias)),
);
result.insert("checkout_slug".into(), json!(encode_identity(alias)));
"available"
} else {
"unsupported"
}
} else {
"disconnected"
};
result.insert("content_state".into(), json!(state));
Ok(())
}
result.insert("path".into(), json!(item_path(domain, id)));
Ok(Json::Object(result))
fn attached_reference(&self, alias: &str, workdir: &str) -> Option<Json> {
if !self.catalog {
return None;
}
// Resolve only the selected alias through current authority, never scan all pages.
// Attach has no response connection ID: this is a separate current observation,
// not proof that the connection cannot change before later Inspect.
let connection = self
.backend
.client
.authorize_workdir_attachment_operation("workdir.attachment.read", alias, None)
.ok()?;
self.authorize(
"WorkdirAttachmentRead",
&self.read_input(Domain::Attachment, &connection),
)
.ok()?;
let raw = self.raw_item(Domain::Attachment, &connection).ok()?;
if raw.get("alias").and_then(Json::as_str) != Some(alias)
|| raw.get("working_directory_id").and_then(Json::as_str) != Some(workdir)
{
return None;
}
self.projected(Domain::Attachment, &raw).ok()
}
}
@@ -848,18 +914,12 @@ impl WipOperationHandler for ItemHandler {
let mut result = raw;
result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id));
result["attachments_path"] = json!(ATTACHMENTS);
if let Some(alias) = input.get("alias").and_then(Json::as_str)
&& self
.provider
.backend
.session_router
.resolve(Some(alias))
.is_ok()
{
result["checkout_path"] = json!(crate::checkout::checkout_root(alias));
result["checkout_slug"] = json!(encode_identity(alias));
}
// Backend attachment list supplies the durable lifetime identity.
result["attachment"] = input
.get("alias")
.and_then(Json::as_str)
.and_then(|alias| self.provider.attached_reference(alias, &self.id))
.map(|value| json!({"$case":"available", "value":value}))
.unwrap_or_else(|| json!({"$case":"unavailable"}));
result
} else {
raw
@@ -1043,6 +1103,7 @@ fn operation(
name: &str,
summary: &str,
parameters: Vec<ParameterDeclaration>,
returns: TypeExpr,
) -> OperationDeclaration {
OperationDeclaration {
name: name.into(),
@@ -1053,7 +1114,7 @@ fn operation(
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Json,
r#type: returns,
},
}
}
@@ -1081,15 +1142,17 @@ fn collection_descriptor(domain: Domain, management: bool) -> InterfaceDescripto
parameter("runtime_id", false, TypeExpr::String),
parameter("display_name", false, TypeExpr::String),
],
result::created(domain),
)]
} else {
vec![operation(
"list",
"List a bounded page of visible Objects with item paths",
"List a bounded page of visible Objects with typed Entries",
vec![
parameter("limit", false, TypeExpr::Integer),
parameter("cursor", false, TypeExpr::String),
],
result::page(domain),
)]
},
)
@@ -1105,14 +1168,21 @@ fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor {
"attach",
ATTACH_DESCRIPTION,
vec![parameter("alias", true, TypeExpr::String)],
result::attached(),
),
operation(
"delete",
DELETE_DESCRIPTION,
vec![parameter("reason", true, TypeExpr::String)],
TypeExpr::Json,
),
],
Domain::Attachment => vec![operation("detach", DETACH_DESCRIPTION, vec![])],
Domain::Attachment => vec![operation(
"detach",
DETACH_DESCRIPTION,
vec![],
TypeExpr::Json,
)],
Domain::Repository => vec![],
}
} else {
@@ -1120,6 +1190,7 @@ fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor {
"read",
"Read this Object through current Workspace authority",
vec![],
result::item(domain),
)]
},
)
@@ -1146,7 +1217,7 @@ pub(crate) mod tests {
Vec<server_api::authorization::BoundOperationAuthorizationRequest>,
pub(crate) authorization_denied: bool,
pub(crate) denied: bool,
config_current: Option<Json>,
pub(crate) config_current: Option<Json>,
generation: usize,
}
impl Default for CatalogClient {
@@ -1447,6 +1518,69 @@ pub(crate) mod tests {
assert!(!projected.to_string().contains("SECRET"));
}
#[tokio::test]
async fn physical_entrance_requires_publication_session_capability_and_exact_connection() {
let client = Arc::new(CatalogClient::default());
let raw = json!({"alias":"checkout", "connection_id":"current", "working_directory_id":"wd", "capabilities":{"bits":63}});
client.state.lock().unwrap().attachments.push(raw.clone());
let mut p = provider(client.clone(), true, true, None);
let project = |p: &Provider, raw: &Json| p.projected(Domain::Attachment, raw).ok().unwrap();
assert_eq!(project(&p, &raw)["content_state"], "unpublished");
p.checkout_content = true;
assert_eq!(project(&p, &raw)["content_state"], "disconnected");
let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap();
p.backend
.session_router
.attach(
alias.clone(),
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
client.clone(),
"checkout",
"wd",
WorkdirSessionCapabilities::EMPTY,
),
)
.unwrap();
assert_eq!(project(&p, &raw)["content_state"], "unsupported");
p.backend.session_router.detach(&alias).await.unwrap();
p.backend
.session_router
.attach(
alias,
WorkspaceAttachedWorkdirSession::handle_for_workdir(client, "checkout", "wd"),
)
.unwrap();
assert_eq!(project(&p, &raw)["content_state"], "available");
let mut old = raw.clone();
old["connection_id"] = json!("old");
let old = project(&p, &old);
assert_eq!(old["content_state"], "unavailable");
assert!(old.get("checkout_path").is_none());
}
#[test]
fn result_contract_validates_entry_coordinates_without_guessing_plain_strings() {
let p = provider(Arc::new(CatalogClient::default()), true, true, None);
let descriptor = item_descriptor(Domain::Repository, false);
let mut raw = json!({"repository_key":"key", "display_name":"not an Entry /?", "default_selector":"refs/heads/feature"});
let projected = p.projected(Domain::Repository, &raw).ok().unwrap();
descriptor
.validate_result("read", &json_to_wip(&projected).unwrap())
.unwrap();
let mut bad = projected;
bad["path"] = json!("not-an-absolute-entry");
assert!(
descriptor
.validate_result("read", &json_to_wip(&bad).unwrap())
.is_err()
);
raw["repository_key"] = json!("repo/name");
let projected = p.projected(Domain::Repository, &raw).ok().unwrap();
descriptor
.validate_result("read", &json_to_wip(&projected).unwrap())
.unwrap();
}
fn provider(
client: Arc<CatalogClient>,
catalog: bool,
@@ -1458,6 +1592,7 @@ pub(crate) mod tests {
catalog,
manage,
config_content: false,
checkout_content: false,
permissions,
}
}
@@ -1832,6 +1967,7 @@ mod lifetime_tests {
catalog: true,
manage: true,
config_content: false,
checkout_content: false,
};
let handler = CollectionHandler {
provider: p.clone(),
@@ -1928,6 +2064,7 @@ mod logical_capability_tests {
catalog: true,
manage: true,
config_content: false,
checkout_content: false,
permissions: None,
};
let item = ItemHandler {
@@ -0,0 +1,699 @@
//! Model-facing Inspect/Invoke integration through real WIP Client/Host, management Feature,
//! WorkspaceAttachedWorkdirSession, checkout/config projections and an isolated LocalWorkdirSession.
//! Only WorkspaceClient transport/Backend ledger is substituted; Backend persistence/authorization
//! is not reimplemented or certified here. Selected Entries come from published return declarations,
//! not result field names or path concatenation. This module belongs to the WIP binding test scope.
use super::*;
use crate::feature::builtin::manage_workdir::{ManageWorkdirFeature, wip::tests::CatalogClient};
use crate::feature::builtin::workspace_config::WorkspaceConfigFeature;
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
};
use std::sync::atomic::{AtomicBool, AtomicUsize};
use tempfile::TempDir;
use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle};
#[derive(Debug)]
struct ContentClient {
catalog: Arc<CatalogClient>,
session: WorkdirSessionHandle,
reference_unavailable: AtomicBool,
content_denied: AtomicBool,
content_reads: AtomicUsize,
}
impl WorkspaceClient for ContentClient {
fn workspace_id(&self) -> Option<&str> {
Some("test-workspace")
}
fn kind(&self) -> &str {
"workdir-model-transport"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/bound-check")
&& self.reference_unavailable.load(Ordering::SeqCst)
&& request
.body
.as_deref()
.unwrap()
.contains("workdir.attachment.read")
{
return Ok(WorkspaceResponse {
status: 403,
body: "denied".into(),
});
}
if request.path.ends_with("/workdir-session/operations") {
self.catalog
.state
.lock()
.unwrap()
.requests
.push(request.clone());
if self.content_denied.load(Ordering::SeqCst) {
return Ok(WorkspaceResponse {
status: 403,
body: serde_json::to_string(
&workdir::http::WorkdirTransportError::from_workdir_error(
&workdir::WorkdirError::Denied("fixture".into()),
),
)
.unwrap(),
});
}
#[derive(serde::Deserialize)]
struct SessionRequest {
target_workdir: String,
operation: workdir::http::WorkdirSessionOperation,
}
let operation: SessionRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(operation.target_workdir, "checkout");
// The synchronous WorkspaceClient boundary runs the actual async local provider on an
// isolated thread/runtime, without nesting block_on in the model's runtime thread.
if matches!(
&operation.operation,
workdir::http::WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest {
operation: workdir::CheckoutOperation::Read { .. }
| workdir::CheckoutOperation::ReadBytes { .. },
..
})
) {
self.content_reads.fetch_add(1, Ordering::SeqCst);
}
let session = self.session.clone();
let result = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(workdir::dispatch_workdir_session_operation(
session.as_ref(),
operation.operation,
))
})
.join()
.unwrap()
.unwrap();
return Ok(reply(&workdir::http::WorkdirSessionOperationResponse {
result,
audit_failures: vec![],
}));
}
if request.path.contains("/workspace-config/") {
self.catalog
.state
.lock()
.unwrap()
.requests
.push(request.clone());
let input: Json = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let current = self
.catalog
.state
.lock()
.unwrap()
.config_current
.clone()
.unwrap();
if input["connection_id"] != current["connection_id"]
|| self.content_denied.load(Ordering::SeqCst)
{
return Ok(WorkspaceResponse {
status: 403,
body: "denied".into(),
});
}
let file = "main.dcdl";
let text = "model = fixture\n";
let digest = config_source::digest_bytes(text.as_bytes());
let validator =
|path: &str| format!("{}:{path}", current["connection_id"].as_str().unwrap());
if request.path.ends_with("/observe") {
let input: server_api::WorkspaceConfigObserveRequest =
serde_json::from_value(input).unwrap();
let nodes = ["", file]
.into_iter()
.filter(|path| {
input.paths.iter().any(|selected| {
selected == path || selected.is_empty() && input.depth > 0
})
})
.map(|path| server_api::WorkspaceConfigNode {
path: path.into(),
kind: if path.is_empty() {
server_api::WorkspaceConfigNodeKind::Directory
} else {
server_api::WorkspaceConfigNodeKind::File
},
validator: validator(path),
digest: (!path.is_empty()).then(|| digest.clone()),
content_type: (!path.is_empty())
.then_some(server_api::ConfigContentType::Decodal),
operations: if path.is_empty() {
vec![]
} else {
vec!["read".into()]
},
})
.collect();
return Ok(reply(&server_api::WorkspaceConfigObserveResponse {
connection_id: current["connection_id"].as_str().unwrap().into(),
validator: validator(""),
digest,
entrypoints: vec![file.into()],
nodes,
}));
}
assert!(request.path.ends_with("/read"));
let input: server_api::WorkspaceConfigReadRequest =
serde_json::from_value(input).unwrap();
assert_eq!(input.path, file);
assert_eq!(input.validator, validator(file));
return Ok(reply(&server_api::WorkspaceConfigReadResponse {
path: input.path,
validator: validator(file),
content: text.into(),
digest,
content_type: server_api::ConfigContentType::Decodal,
}));
}
self.catalog.execute(request)
}
}
fn reply(value: &impl serde::Serialize) -> WorkspaceResponse {
WorkspaceResponse {
status: 200,
body: serde_json::to_string(value).unwrap(),
}
}
struct Fixture {
_directory: TempDir,
client: Arc<ContentClient>,
runtime: Arc<WipRuntime>,
tools: BTreeMap<String, Arc<dyn Tool>>,
}
impl Fixture {
fn new(checkout: bool, config: bool, permissions: Option<ToolPermissionConfig>) -> Self {
Self::with_config_content(checkout, config, true, permissions)
}
fn with_config_content(
checkout: bool,
config: bool,
content: bool,
permissions: Option<ToolPermissionConfig>,
) -> Self {
let directory = TempDir::new().unwrap();
std::fs::write(directory.path().join("sample.txt"), "isolated content\n").unwrap();
let client = Arc::new(ContentClient {
catalog: Arc::new(CatalogClient::default()),
session: Arc::new(LocalWorkdirSession::materialized(
directory.path().into(),
directory.path().into(),
manifest::SharedScope::new(manifest::Scope::writable(directory.path()).unwrap()),
WorkdirSessionCapabilities::READ_ONLY,
)),
reference_unavailable: AtomicBool::new(false),
content_denied: AtomicBool::new(false),
content_reads: AtomicUsize::new(0),
});
let router = Arc::new(workdir::WorkdirSessionRouter::new());
let feature = ManageWorkdirFeature::with_child_lifecycle(
client.clone(),
router.clone(),
Arc::new(|| Box::pin(async { Ok(()) })),
Arc::new(|| {}),
);
let mut registry = WipMountRegistry::new();
if checkout {
crate::checkout::mount_checkouts(
&mut registry,
router,
tools::Tracker::new(),
permissions.clone(),
)
.unwrap();
}
if config {
let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true);
if content {
crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
&mut registry,
&feature,
)
.unwrap();
} else {
crate::feature::builtin::workspace_config::wip::mount_workspace_config_attach_wip(
&mut registry,
&feature,
)
.unwrap();
}
}
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut registry,
&feature,
true,
true,
permissions,
)
.unwrap();
let runtime =
Arc::new(WipRuntime::from_mounts(registry, "workdir-model-test".into()).unwrap());
let tools = wip_tool_definitions(runtime.clone())
.into_iter()
.map(|definition| {
let (meta, tool) = definition();
(meta.name, tool)
})
.collect();
Self {
_directory: directory,
client,
runtime,
tools,
}
}
async fn execute(&self, tool: &str, input: Json) -> Result<Json, ToolError> {
self.tools[tool]
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.map(|output| serde_json::from_str(output.content.as_deref().unwrap()).unwrap())
}
async fn inspect(&self, path: &str) -> Json {
self.execute("Inspect", json!({"path":path})).await.unwrap()
}
async fn request(&self, path: &str, operation: &str, arguments: Json) -> (Json, TypeExpr) {
let object = self.inspect(path).await;
for interface in object["interfaces"].as_array().unwrap() {
let reference = InterfaceReference {
scope: interface["reference"]["scope"].as_str().unwrap().into(),
name: interface["reference"]["name"].as_str().unwrap().into(),
};
let descriptor = self
.runtime
.host
.fetch_interface_live(&reference)
.await
.unwrap()
.descriptor;
if let Some(op) = descriptor.operations.iter().find(|op| op.name == operation) {
let display = self
.execute("Inspect", json!({"path":interface["path"]}))
.await
.unwrap();
assert_eq!(
display["interface_signature"],
wip_text_view::render_interface(&wip_text_view::Interface {
reference: &reference,
descriptor: &descriptor,
})
.unwrap()
);
return (
json!({"path":path,"interface":interface["reference"],"operation":operation,"arguments":arguments}),
op.returns.r#type.clone(),
);
}
}
panic!("no published {operation} for {path}")
}
async fn invoke(&self, path: &str, operation: &str, arguments: Json) -> (Json, Vec<String>) {
let (request, ty) = self.request(path, operation, arguments).await;
let value = self.execute("Invoke", request).await.unwrap();
let mut entries = Vec::new();
collect_entries(&ty, &value, &mut entries);
(value, entries)
}
fn writes(&self, suffix: &str) -> usize {
self.client
.catalog
.state
.lock()
.unwrap()
.requests
.iter()
.filter(|request| {
request.method != WorkspaceRequestMethod::Get && request.path.ends_with(suffix)
})
.count()
}
}
fn collect_entries(ty: &TypeExpr, value: &Json, entries: &mut Vec<String>) {
match ty {
TypeExpr::Entry => entries.push(value.as_str().expect("Entry wire string").into()),
TypeExpr::Record { fields } => {
for field in fields {
if let Some(value) = value.get(&field.name) {
collect_entries(&field.r#type, value, entries);
}
}
}
TypeExpr::List { items } => {
for value in value.as_array().unwrap() {
collect_entries(items, value, entries)
}
}
TypeExpr::Union { cases } => {
let case = cases
.iter()
.find(|case| value["$case"] == case.name)
.unwrap();
if let Some(payload) = &case.payload {
collect_entries(payload, &value["value"], entries);
}
}
// Neither ordinary Strings nor JSON objects containing path-shaped strings are Entries.
_ => (),
}
}
fn selected(entries: &[String], root: &str) -> String {
entries
.iter()
.find(|entry| entry.starts_with(root) && entry.as_str() != root)
.unwrap()
.clone()
}
#[tokio::test]
async fn model_repository_create_attach_selected_entries_read_and_detach_preserve_workdir() {
let f = Fixture::new(true, false, None);
let (listed, entries) = f.invoke("/repositories", "list", json!({})).await;
assert_eq!(entries.len(), 1);
let repository = entries[0].clone();
let (read, _) = f.invoke(&repository, "read", json!({})).await;
assert_eq!(read["repository_key"], listed["items"][0]["repository_key"]);
let (created, entries) = f
.invoke(
"/workdirs",
"create",
json!({"repository_key":read["repository_key"]}),
)
.await;
assert_eq!(f.writes("working-directories"), 1);
assert!(
f.client
.catalog
.state
.lock()
.unwrap()
.attachments
.is_empty()
);
let workdir = selected(&entries, "/workdirs");
assert!(entries.contains(&repository));
let (attached, entries) = f
.invoke(&workdir, "attach", json!({"alias":"checkout"}))
.await;
assert_eq!(attached["attached"], true);
assert_eq!(attached["attachment"]["$case"], "available");
assert_eq!(
f.writes("workdir-session/operations"),
0,
"returning Entries must not observe content eagerly"
);
let attachment = selected(&entries, "/workdir-attachments");
let checkout = selected(&entries, "/checkouts");
let (_, read_entries) = f.invoke(&attachment, "read", json!({})).await;
assert!(read_entries.contains(&checkout));
let (_, files) = f.invoke(&checkout, "list", json!({})).await;
let file = files.first().unwrap();
let (read, _) = f.invoke(file, "read", json!({})).await;
assert!(
read["content"]
.as_str()
.unwrap()
.contains("isolated content")
);
assert_eq!(f.writes("workdir-attachments"), 1);
f.invoke(&attachment, "detach", json!({})).await;
assert!(
f.execute("Inspect", json!({"path":attachment,"refresh":true}))
.await
.is_err()
);
assert!(
f.execute("Inspect", json!({"path":checkout,"refresh":true}))
.await
.is_err()
);
assert_eq!(f.client.catalog.state.lock().unwrap().workdirs.len(), 1);
assert_eq!(created["item"]["working_directory_id"], "wd-created");
}
#[tokio::test]
async fn model_config_attachment_entry_reaches_logical_content_without_physical_session() {
let f = Fixture::new(false, true, None);
let current = json!({"workspace_id":"test-workspace", "alias":"workspace-config", "connection_id":"logical-connection", "working_directory_id":"logical-workdir", "access":"read_only", "name":"Config", "purpose":"Configuration", "content_path":"/workspace-config", "already_attached":true});
{
let mut state = f.client.catalog.state.lock().unwrap();
state.config_current = Some(current.clone());
state.attachments.push(json!({"alias":current["alias"], "connection_id":current["connection_id"], "working_directory_id":current["working_directory_id"], "capabilities":{"bits":25}}));
}
let (_, entries) = f.invoke("/workdir-attachments", "list", json!({})).await;
let attachment = selected(&entries, "/workdir-attachments");
let (_, entries) = f.invoke(&attachment, "read", json!({})).await;
let entrance = entries
.iter()
.find(|entry| entry.as_str() == "/workspace-config")
.unwrap();
assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts")));
f.inspect(entrance).await;
let tree = f
.execute("Tree", json!({"path":entrance, "depth":1}))
.await
.unwrap();
let file = tree["tree"]["children"][0]["path"].as_str().unwrap();
let (read, _) = f.invoke(file, "read", json!({})).await;
assert_eq!(read["content"], "model = fixture\n");
assert_eq!(f.writes("workdir-session/operations"), 0);
assert_eq!(f.writes("workdir-attachments"), 0);
let (old_read, _) = f.request(file, "read", json!({})).await;
{
let mut state = f.client.catalog.state.lock().unwrap();
state.config_current.as_mut().unwrap()["connection_id"] = json!("logical-reconnected");
state.attachments[0]["connection_id"] = json!("logical-reconnected");
}
let reads = f.writes("workspace-config/read");
assert!(f.execute("Invoke", old_read).await.is_err());
assert_eq!(
f.writes("workspace-config/read"),
reads,
"stale logical observation must not read the replacement connection"
);
assert!(
f.execute("Inspect", json!({"path":attachment,"refresh":true}))
.await
.is_err()
);
f.execute("Inspect", json!({"path":file,"refresh":true}))
.await
.unwrap();
f.invoke(file, "read", json!({})).await;
f.client.catalog.state.lock().unwrap().config_current = None;
assert!(
f.execute("Inspect", json!({"path":file,"refresh":true}))
.await
.is_err()
);
}
async fn create_and_attach(f: &Fixture) -> (String, Json, Vec<String>) {
let (_, entries) = f
.invoke(
"/workdirs",
"create",
json!({"repository_key":"registered-key"}),
)
.await;
let workdir = selected(&entries, "/workdirs");
let (attached, entries) = f
.invoke(&workdir, "attach", json!({"alias":"checkout"}))
.await;
(workdir, attached, entries)
}
#[tokio::test]
async fn model_successful_mutations_survive_unavailable_references_without_reexecution() {
let f = Fixture::new(true, false, None);
f.client.reference_unavailable.store(true, Ordering::SeqCst);
let (workdir, attached, entries) = create_and_attach(&f).await;
assert_eq!(attached["attached"], true);
assert_eq!(attached["attachment"]["$case"], "unavailable");
assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts")));
assert_eq!(f.writes("working-directories"), 1);
assert_eq!(f.writes("workdir-attachments"), 1);
f.client
.reference_unavailable
.store(false, Ordering::SeqCst);
let (_, entries) = f.invoke("/workdir-attachments", "list", json!({})).await;
let attachment = selected(&entries, "/workdir-attachments");
let checkout = selected(&entries, "/checkouts");
f.invoke(&attachment, "detach", json!({})).await;
// Both successful mutation results remain successful when selected coordinates later disappear.
assert!(
f.execute("Inspect", json!({"path":checkout, "refresh":true}))
.await
.is_err()
);
f.client.catalog.state.lock().unwrap().workdirs.clear();
assert!(
f.execute("Inspect", json!({"path":workdir, "refresh":true}))
.await
.is_err()
);
assert_eq!(f.writes("working-directories"), 1);
assert_eq!(f.writes("workdir-attachments"), 1);
assert!(
f.runtime
.audit()
.iter()
.all(|audit| audit.outcome == WipAuditOutcome::Success)
);
}
#[tokio::test]
async fn model_alias_reuse_rejects_old_attachment_and_content_observations() {
let f = Fixture::new(true, false, None);
let (workdir, _, entries) = create_and_attach(&f).await;
let old_attachment = selected(&entries, "/workdir-attachments");
let checkout = selected(&entries, "/checkouts");
let (_, files) = f.invoke(&checkout, "list", json!({})).await;
let (old_read, _) = f.request(&files[0], "read", json!({})).await;
let (old_detach, _) = f.request(&old_attachment, "detach", json!({})).await;
f.execute("Invoke", old_detach.clone()).await.unwrap();
let (_, new_entries) = f
.invoke(&workdir, "attach", json!({"alias":"checkout"}))
.await;
let new_attachment = selected(&new_entries, "/workdir-attachments");
assert_ne!(old_attachment, new_attachment);
assert!(
new_entries.contains(&checkout),
"coordinate reuse is not observation reuse"
);
let before = f.client.content_reads.load(Ordering::SeqCst);
assert!(f.execute("Invoke", old_read).await.is_err());
assert_eq!(f.client.content_reads.load(Ordering::SeqCst), before);
assert!(f.execute("Invoke", old_detach).await.is_err());
assert_eq!(f.client.catalog.state.lock().unwrap().attachments.len(), 1);
assert!(
f.execute("Inspect", json!({"path":old_attachment, "refresh":true}))
.await
.is_err()
);
f.execute("Inspect", json!({"path":checkout, "refresh":true}))
.await
.unwrap();
let (_, files) = f.invoke(&checkout, "list", json!({})).await;
// Explicit refresh observes the new generation rather than retrying the old call.
f.execute("Inspect", json!({"path":files[0], "refresh":true}))
.await
.unwrap();
let (read, _) = f.invoke(&files[0], "read", json!({})).await;
assert!(
read["content"]
.as_str()
.unwrap()
.contains("isolated content")
);
}
#[tokio::test]
async fn model_content_entry_never_grants_read_or_write_authority() {
let f = Fixture::new(true, false, None);
let (_, _, entries) = create_and_attach(&f).await;
let checkout = selected(&entries, "/checkouts");
f.client.content_denied.store(true, Ordering::SeqCst);
assert!(
f.execute("Inspect", json!({"path":checkout}))
.await
.is_err()
);
f.client.content_denied.store(false, Ordering::SeqCst);
f.execute("Inspect", json!({"path":checkout, "refresh":true}))
.await
.unwrap();
let (_, files) = f.invoke(&checkout, "list", json!({})).await;
let object = f.inspect(&files[0]).await;
for interface in object["interfaces"].as_array().unwrap() {
let reference = InterfaceReference {
scope: interface["reference"]["scope"].as_str().unwrap().into(),
name: interface["reference"]["name"].as_str().unwrap().into(),
};
let descriptor = f
.runtime
.host
.fetch_interface_live(&reference)
.await
.unwrap()
.descriptor;
assert!(
!descriptor
.operations
.iter()
.any(|operation| matches!(operation.name.as_str(), "write" | "edit" | "command"))
);
}
let (read, _) = f.request(&files[0], "read", json!({})).await;
f.client.content_denied.store(true, Ordering::SeqCst);
assert!(
f.execute("Invoke", read).await.is_err(),
"current authority is rechecked"
);
}
#[tokio::test]
async fn model_plain_path_strings_stay_data_and_pages_expose_only_selected_entries() {
let f = Fixture::new(false, false, None);
{
let mut state = f.client.catalog.state.lock().unwrap();
state.repositories[0]["display_name"] = json!("/workdirs/not-an-entry");
state.repositories[0]["default_selector"] = json!("/checkouts/not-an-entry");
state.repositories.push(json!({"repository_key":"second"}));
}
let (first, entries) = f.invoke("/repositories", "list", json!({"limit":1})).await;
assert_eq!(entries.len(), 1);
assert_eq!(first["has_more"], true);
assert!(!entries.iter().any(|entry| entry.contains("not-an-entry")));
let (second, entries) = f
.invoke(
"/repositories",
"list",
json!({"limit":1, "cursor":first["next_cursor"]}),
)
.await;
assert_eq!(entries.len(), 1);
assert_eq!(second["items"][0]["repository_key"], "second");
let (empty, entries) = f.invoke("/workdirs", "list", json!({})).await;
assert_eq!(empty["empty"], true);
assert!(entries.is_empty());
let (_, attached, entries) = create_and_attach(&f).await;
assert_eq!(
attached["attachment"]["value"]["content_state"],
"unpublished"
);
assert!(!entries.iter().any(|entry| entry.starts_with("/checkouts")));
}
#[tokio::test]
async fn model_self_attach_only_root_does_not_publish_a_config_content_entry() {
let f = Fixture::with_config_content(false, true, false, None);
let current = json!({"workspace_id":"test-workspace", "alias":"workspace-config", "connection_id":"logical-connection", "working_directory_id":"logical-workdir", "access":"read_only", "name":"Config", "purpose":"Configuration", "content_path":"/workspace-config", "already_attached":true});
{
let mut state = f.client.catalog.state.lock().unwrap();
state.config_current = Some(current.clone());
state.attachments.push(json!({"alias":current["alias"], "connection_id":current["connection_id"], "working_directory_id":current["working_directory_id"], "capabilities":{"bits":25}}));
}
let object = f.inspect("/workspace-config").await;
assert_eq!(object["path"], "/workspace-config");
let (listed, entries) = f.invoke("/workdir-attachments", "list", json!({})).await;
assert_eq!(listed["items"][0]["content_state"], "unpublished");
assert!(!entries.contains(&"/workspace-config".into()));
}
@@ -0,0 +1,144 @@
//! Native result declarations; identity data never acquires Entry semantics by its spelling.
use super::Domain;
use wip_protocol::{Documentation, EnumCase, FieldDeclaration, TypeExpr, UnionCase};
fn field(name: &str, required: bool, ty: TypeExpr) -> FieldDeclaration {
FieldDeclaration {
name: name.into(),
required,
documentation: None,
r#type: ty,
}
}
fn record(fields: Vec<FieldDeclaration>) -> TypeExpr {
TypeExpr::Record { fields }
}
pub(super) fn item(domain: Domain) -> TypeExpr {
let mut fields = vec![field("path", true, TypeExpr::Entry)];
let data: &[&str] = match domain {
Domain::Repository => &[
"repository_key",
"kind",
"provider",
"display_name",
"description",
"default_selector",
],
Domain::Workdir => &[
"working_directory_id",
"display_name",
"creation_selector",
"creation_ref",
"creation_tree",
"current_selector",
"current_ref",
"current_tree",
"status",
"cleanliness",
"occupied_by",
],
Domain::Attachment => &[
"connection_id",
"alias",
"working_directory_id",
"capabilities",
"name",
"purpose",
"access",
],
};
let identities: &[&str] = match domain {
Domain::Repository => &["repository_key"],
Domain::Workdir => &["working_directory_id"],
Domain::Attachment => &["connection_id", "alias", "working_directory_id"],
};
for name in data {
// Nullable/structured Backend metadata remains ordinary JSON, never navigable references.
fields.push(if identities.contains(name) {
field(name, true, TypeExpr::String)
} else {
field(name, false, TypeExpr::Json)
});
}
if matches!(domain, Domain::Workdir) {
fields.push(field(
"source",
false,
record(vec![
field("kind", true, TypeExpr::String),
field("repository_key", false, TypeExpr::String),
field("repository_path", false, TypeExpr::Entry),
field("access", false, TypeExpr::String),
// Source mapping only: no attached connection, therefore not an Entry.
field("content_path", false, TypeExpr::String),
field("purpose", false, TypeExpr::String),
]),
));
}
if matches!(domain, Domain::Attachment) {
for name in ["workdir_path", "checkout_path", "content_path"] {
fields.push(field(name, false, TypeExpr::Entry));
}
fields.push(field(
"content_state",
true,
TypeExpr::Enum {
cases: [
("available", "Current entrance coordinate only; content and access are not acquired"),
("unpublished", "Native content subtree is not registered; a self-attach root alone is insufficient"),
("disconnected", "No matching current content connection/session"),
("unsupported", "Attached session does not support content Read"),
("unavailable", "Exact connection could not be confirmed; rediscover, do not repeat mutation"),
]
.into_iter()
.map(|(name, summary)| EnumCase {
name: name.into(),
documentation: Some(Documentation { summary:summary.into(), details:None }),
})
.collect(),
},
));
}
record(fields)
}
pub(super) fn page(domain: Domain) -> TypeExpr {
record(vec![
field(
"items",
true,
TypeExpr::List {
items: Box::new(item(domain)),
},
),
field("empty", true, TypeExpr::Boolean),
field("has_more", true, TypeExpr::Boolean),
field("next_cursor", true, TypeExpr::Json),
field("limit", true, TypeExpr::Integer),
])
}
pub(super) fn created(domain: Domain) -> TypeExpr {
record(vec![
field("item", true, item(domain)),
field("runtime_id", true, TypeExpr::String),
])
}
pub(super) fn attached() -> TypeExpr {
record(vec![
field("workspace_id", true, TypeExpr::String),
field("alias", true, TypeExpr::String),
field("working_directory_id", true, TypeExpr::String),
field("capabilities", true, TypeExpr::Json),
field("environment", true, TypeExpr::Json),
field("attached", true, TypeExpr::Boolean),
field("workdir_path", true, TypeExpr::Entry),
field("attachments_path", true, TypeExpr::Entry),
field("attachment", true, TypeExpr::Union { cases: vec![
UnionCase { name:"available".into(), documentation:None, payload:Some(item(Domain::Attachment)) },
UnionCase { name:"unavailable".into(), documentation:Some(Documentation {
summary:"Attach succeeded; reference lookup unavailable. Rediscover attachments; do not repeat attach.".into(), details:None
}), payload:None },
] }),
])
}
+9
View File
@@ -1149,6 +1149,11 @@ impl WipMountRegistry {
Ok(())
}
/// Exact registration only; this does not observe content or establish current authority.
pub(crate) fn has_subtree(&self, root: &str) -> bool {
self.subtree_mounts.iter().any(|mount| mount.root == root)
}
pub fn routes(&self) -> impl Iterator<Item = &str> {
self.mounts.keys().map(String::as_str)
}
@@ -7269,3 +7274,7 @@ mod tests {
#[cfg(test)]
mod bash_output_tests;
#[cfg(test)]
#[path = "feature/builtin/manage_workdir/wip/model_tests.rs"]
mod workdir_model_tests;