fix: preserve native plan filters and disclose input coordinates
This commit is contained in:
@@ -130,9 +130,25 @@ async fn mr_only_controller_reserves_ticket_coordinates_without_publishing_ticke
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(definition.content.unwrap().contains("ticket: entry"));
|
let definition: serde_json::Value = serde_json::from_str(&definition.content.unwrap()).unwrap();
|
||||||
|
let signature = definition["interface_signature"].as_str().unwrap();
|
||||||
|
assert!(signature.contains("ticket: entry"));
|
||||||
|
let doc = signature
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||||
|
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||||
|
.find(|text| text.starts_with("Ticket Entry: collection root "))
|
||||||
|
.unwrap();
|
||||||
|
let root: String = serde_json::from_str(
|
||||||
|
doc.strip_prefix("Ticket Entry: collection root ")
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let (_, invoke) = tools.get_tool("Invoke").unwrap();
|
let (_, invoke) = tools.get_tool("Invoke").unwrap();
|
||||||
let input = serde_json::json!({"path":"/merge-requests","interface":object["interfaces"][0]["reference"],"operation":"open","arguments":{"ticket":"/tickets/T-42","repository":{"key":"main","source":"work/T-42","target":"develop"}}});
|
let input = serde_json::json!({"path":"/merge-requests","interface":object["interfaces"][0]["reference"],"operation":"open","arguments":{"ticket":format!("{root}/T-42"),"repository":{"key":"main","source":"work/T-42","target":"develop"}}});
|
||||||
invoke
|
invoke
|
||||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -869,7 +869,7 @@ fn merge_request_descriptor(
|
|||||||
fn merge_request_operation_declaration(
|
fn merge_request_operation_declaration(
|
||||||
tool: &NativeMergeRequestTool,
|
tool: &NativeMergeRequestTool,
|
||||||
) -> Result<OperationDeclaration, WipMountError> {
|
) -> Result<OperationDeclaration, WipMountError> {
|
||||||
Ok(native::declaration(&tool.contract))
|
Ok(native::declaration(&tool.contract, &tool.roots))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_request_protocol_failure(
|
fn merge_request_protocol_failure(
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
//! consumers must Inspect the current Object and then its Interface again, use Ticket Entries,
|
//! consumers must Inspect the current Object and then its Interface again, use Ticket Entries,
|
||||||
//! group repository/page/integration records, and invoke `readiness`. Tool mode is unchanged.
|
//! group repository/page/integration records, and invoke `readiness`. Tool mode is unchanged.
|
||||||
//!
|
//!
|
||||||
//! Examples below show Invoke's JSON binding, not Tool JSON. Substitute the actual allocated
|
//! Examples below show Invoke's JSON binding, not Tool JSON. Read the allocated Ticket collection
|
||||||
//! Ticket collection path discovered from the current registry (it need not be `/tickets`).
|
//! root from the Entry parameter's Inspect documentation (it need not be `/tickets`). This input
|
||||||
|
//! coordinate remains available when Ticket Objects are unpublished; it is not an observation or grant.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! collection.open({ticket: "/tickets/T-797",
|
//! collection.open({ticket: "/tickets/T-797",
|
||||||
@@ -186,8 +187,18 @@ pub(super) fn contract(kind: Kind) -> Contract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn declaration(contract: &Contract) -> OperationDeclaration {
|
impl ReferenceRoots {
|
||||||
contract.declaration()
|
fn contract_roots(&self) -> crate::feature::builtin::native_contract::ReferenceRoots<'_> {
|
||||||
|
crate::feature::builtin::native_contract::ReferenceRoots {
|
||||||
|
ticket: &self.ticket,
|
||||||
|
merge_request: &self.merge_request,
|
||||||
|
objective: &self.objective,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn declaration(contract: &Contract, roots: &ReferenceRoots) -> OperationDeclaration {
|
||||||
|
contract.declaration(&roots.contract_roots())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn convert(
|
pub(super) fn convert(
|
||||||
@@ -195,14 +206,7 @@ pub(super) fn convert(
|
|||||||
arguments: &BTreeMap<String, WipValue>,
|
arguments: &BTreeMap<String, WipValue>,
|
||||||
roots: &ReferenceRoots,
|
roots: &ReferenceRoots,
|
||||||
) -> Result<Value, WipOperationError> {
|
) -> Result<Value, WipOperationError> {
|
||||||
contract.convert(
|
contract.convert(arguments, &roots.contract_roots())
|
||||||
arguments,
|
|
||||||
&crate::feature::builtin::native_contract::ReferenceRoots {
|
|
||||||
ticket: &roots.ticket,
|
|
||||||
merge_request: &roots.merge_request,
|
|
||||||
objective: &roots.objective,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn command(kind: Kind, mut input: Value) -> Result<command::Command, WipOperationError> {
|
pub(super) fn command(kind: Kind, mut input: Value) -> Result<command::Command, WipOperationError> {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn inspect_interface(runtime: &WipRuntime, path: &str) -> String {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_contract_exposes_records_enums_nullable_and_entry_without_tool_schema() {
|
fn native_contract_exposes_records_enums_nullable_and_entry_without_tool_schema() {
|
||||||
let open = contract(Kind::Open).declaration();
|
let open = contract(Kind::Open).declaration(&roots().contract_roots());
|
||||||
assert!(matches!(open.parameters[0].r#type, TypeExpr::Entry));
|
assert!(matches!(open.parameters[0].r#type, TypeExpr::Entry));
|
||||||
let TypeExpr::Record { ref fields } = open.parameters[1].r#type else {
|
let TypeExpr::Record { ref fields } = open.parameters[1].r#type else {
|
||||||
panic!("repository record")
|
panic!("repository record")
|
||||||
@@ -71,7 +71,7 @@ fn native_contract_exposes_records_enums_nullable_and_entry_without_tool_schema(
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
[("key", true), ("source", true), ("target", true)]
|
[("key", true), ("source", true), ("target", true)]
|
||||||
);
|
);
|
||||||
let review = contract(Kind::Review).declaration();
|
let review = contract(Kind::Review).declaration(&roots().contract_roots());
|
||||||
assert!(review.parameters[0].required);
|
assert!(review.parameters[0].required);
|
||||||
assert!(matches!(review.parameters[0].r#type, TypeExpr::Enum { .. }));
|
assert!(matches!(review.parameters[0].r#type, TypeExpr::Enum { .. }));
|
||||||
let TypeExpr::List { items } = &review.parameters[2].r#type else {
|
let TypeExpr::List { items } = &review.parameters[2].r#type else {
|
||||||
@@ -405,3 +405,88 @@ async fn native_review_typed_findings_reaches_only_injected_route_and_never_retr
|
|||||||
);
|
);
|
||||||
assert_eq!(body["capability_token"], "secret-capability");
|
assert_eq!(body["capability_token"], "secret-capability");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mr_only_inspect_supplies_reserved_ticket_coordinates_without_observing_ticket() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
||||||
|
json!({"merge_request_id":"MR-1"}),
|
||||||
|
)]));
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
registry
|
||||||
|
.allocate_namespace("ticket", "hidden-cases")
|
||||||
|
.unwrap();
|
||||||
|
let route = registry
|
||||||
|
.allocate_namespace("merge-request", "changes")
|
||||||
|
.unwrap();
|
||||||
|
mount_workspace_http_merge_request_wip(
|
||||||
|
&mut registry,
|
||||||
|
client.clone(),
|
||||||
|
MergeRequestFeatureConfig {
|
||||||
|
open: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
&route,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let runtime = WipRuntime::from_mounts(registry, "mr-only-input-discovery".into()).unwrap();
|
||||||
|
let tree = runtime.tree("/".into(), 1, false).await.unwrap();
|
||||||
|
assert!(!tree.content.unwrap().contains("hidden-cases"));
|
||||||
|
let object = runtime.inspect("/changes".into(), false).await.unwrap();
|
||||||
|
let object: Value = serde_json::from_str(&object.content.unwrap()).unwrap();
|
||||||
|
let shown = &object["interfaces"][0];
|
||||||
|
let definition = runtime
|
||||||
|
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let definition: Value = serde_json::from_str(&definition.content.unwrap()).unwrap();
|
||||||
|
let signature = definition["interface_signature"].as_str().unwrap();
|
||||||
|
// The model has a known Ticket ID, but obtains the required input coordinates solely
|
||||||
|
// from the native Inspect contract; it never observes or invokes the reserved domain.
|
||||||
|
let doc = signature
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||||
|
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||||
|
.find(|text| text.starts_with("Ticket Entry: collection root "))
|
||||||
|
.unwrap();
|
||||||
|
let encoded = doc
|
||||||
|
.strip_prefix("Ticket Entry: collection root ")
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
let root: String = serde_json::from_str(encoded).unwrap();
|
||||||
|
assert_eq!(root, "/hidden-cases");
|
||||||
|
assert!(signature.contains("Coordinates do not imply Object publication"));
|
||||||
|
assert!(runtime.inspect(root.clone(), false).await.is_err());
|
||||||
|
let input = json!({"ticket":format!("{root}/T-797"),"repository":{"key":"main","source":"work","target":"develop"}});
|
||||||
|
runtime
|
||||||
|
.invoke(
|
||||||
|
"/changes".into(),
|
||||||
|
wip_protocol::InterfaceReference {
|
||||||
|
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||||
|
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||||
|
},
|
||||||
|
"open".into(),
|
||||||
|
input,
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].path, "/api/w/ws/tickets/T-797/merge-request");
|
||||||
|
assert_eq!(
|
||||||
|
client.authorizations.lock().unwrap()[0].resource,
|
||||||
|
server_api::authorization::PermissionResource::Ticket {
|
||||||
|
workspace_id: "ws".into(),
|
||||||
|
ticket_id: "T-797".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
runtime
|
||||||
|
.inspect(format!("{root}/T-797"), false)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
//! These shapes are authored for native operations, not derived from Tool schemas. WIP validates
|
//! These shapes are authored for native operations, not derived from Tool schemas. WIP validates
|
||||||
//! the generated declaration before destination mapping; authorization and business constraints
|
//! the generated declaration before destination mapping; authorization and business constraints
|
||||||
//! remain with the domain handler. Conversion neither resolves objects nor grants access.
|
//! remain with the domain handler. Conversion neither resolves objects nor grants access.
|
||||||
|
//! Declaration and conversion receive the same allocated reference roots. Input Entry documentation
|
||||||
|
//! exposes these coordinates even for unpublished domains, without claiming an Object observation;
|
||||||
|
//! the Interface validator changes with the coordinates and Backend authority is checked at execution.
|
||||||
//!
|
//!
|
||||||
//! WIP 0.2 represents unions as records with `$case` and an optional `value`, and has no nullable
|
//! WIP 0.2 represents unions as records with `$case` and an optional `value`, and has no nullable
|
||||||
//! type. Nullable inputs therefore expose an explicit `null | value(T)` union rather than an
|
//! type. Nullable inputs therefore expose an explicit `null | value(T)` union rather than an
|
||||||
@@ -88,7 +91,7 @@ pub struct ReferenceRoots<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Contract {
|
impl Contract {
|
||||||
pub fn declaration(&self) -> OperationDeclaration {
|
pub fn declaration(&self, roots: &ReferenceRoots<'_>) -> OperationDeclaration {
|
||||||
OperationDeclaration {
|
OperationDeclaration {
|
||||||
name: self.name.into(),
|
name: self.name.into(),
|
||||||
documentation: Some(Documentation {
|
documentation: Some(Documentation {
|
||||||
@@ -101,8 +104,8 @@ impl Contract {
|
|||||||
.map(|input| ParameterDeclaration {
|
.map(|input| ParameterDeclaration {
|
||||||
name: input.name.into(),
|
name: input.name.into(),
|
||||||
required: input.required,
|
required: input.required,
|
||||||
documentation: input.ty.documentation(),
|
documentation: input.ty.documentation(roots),
|
||||||
r#type: input.ty.declaration(),
|
r#type: input.ty.declaration(roots),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
// Typed result Entries are owned separately from this input contract.
|
// Typed result Entries are owned separately from this input contract.
|
||||||
@@ -122,7 +125,7 @@ impl Contract {
|
|||||||
format: INTERFACE_FORMAT_V1.into(),
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
documentation: None,
|
documentation: None,
|
||||||
types: Vec::new(),
|
types: Vec::new(),
|
||||||
operations: vec![self.declaration()],
|
operations: vec![self.declaration(roots)],
|
||||||
};
|
};
|
||||||
descriptor.validate().map_err(invalid)?;
|
descriptor.validate().map_err(invalid)?;
|
||||||
// validate_call is the upstream type validator. These inert targets exist only to
|
// validate_call is the upstream type validator. These inert targets exist only to
|
||||||
@@ -150,29 +153,49 @@ impl Contract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Shape {
|
impl Shape {
|
||||||
fn documentation(&self) -> Option<Documentation> {
|
fn documentation(&self, roots: &ReferenceRoots<'_>) -> Option<Documentation> {
|
||||||
let summary = match self {
|
let summary = match self {
|
||||||
Self::Reference(ReferenceKind::Ticket) => {
|
Self::Reference(kind) => {
|
||||||
"Ticket Entry: an exact item path under the current Ticket collection"
|
let name = match kind {
|
||||||
|
ReferenceKind::Ticket => "Ticket",
|
||||||
|
ReferenceKind::MergeRequest => "Merge Request",
|
||||||
|
ReferenceKind::Objective => "Objective",
|
||||||
|
};
|
||||||
|
let root = kind.root(roots);
|
||||||
|
if root.is_empty() {
|
||||||
|
format!(
|
||||||
|
"{name} Entry: no unique input collection is available; no reference is accepted"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let leaf = match kind {
|
||||||
|
ReferenceKind::Ticket => {
|
||||||
|
"T- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
||||||
}
|
}
|
||||||
Self::Reference(ReferenceKind::MergeRequest) => {
|
ReferenceKind::Objective => {
|
||||||
"Merge Request Entry: an exact item path under the current Merge Request collection"
|
"O- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
||||||
|
}
|
||||||
|
ReferenceKind::MergeRequest => {
|
||||||
|
"1–128 ASCII alphanumeric or -_.~ characters, excluding . and .."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{name} Entry: collection root {root:?}; append /<id> for an exact item path; id is {leaf}. Coordinates do not imply Object publication, existence, observation or execution authority; Backend authorization is checked at call time"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Self::Reference(ReferenceKind::Objective) => {
|
|
||||||
"Objective Entry: an exact item path under the current Objective collection; leaf O- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
|
||||||
}
|
}
|
||||||
Self::Nullable(_) => {
|
Self::Nullable(_) => {
|
||||||
"Explicit null uses {$case: null}; a present value uses {$case: value, value: T}"
|
"Explicit null uses {$case: null}; a present value uses {$case: value, value: T}"
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(Documentation {
|
Some(Documentation {
|
||||||
summary: summary.into(),
|
summary,
|
||||||
details: None,
|
details: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn declaration(&self) -> TypeExpr {
|
fn declaration(&self, roots: &ReferenceRoots<'_>) -> TypeExpr {
|
||||||
match self {
|
match self {
|
||||||
Self::String => TypeExpr::String,
|
Self::String => TypeExpr::String,
|
||||||
Self::Boolean => TypeExpr::Boolean,
|
Self::Boolean => TypeExpr::Boolean,
|
||||||
@@ -195,13 +218,13 @@ impl Shape {
|
|||||||
},
|
},
|
||||||
UnionCase {
|
UnionCase {
|
||||||
name: "value".into(),
|
name: "value".into(),
|
||||||
documentation: inner.documentation(),
|
documentation: inner.documentation(roots),
|
||||||
payload: Some(inner.declaration()),
|
payload: Some(inner.declaration(roots)),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Self::List(items) => TypeExpr::List {
|
Self::List(items) => TypeExpr::List {
|
||||||
items: Box::new(items.declaration()),
|
items: Box::new(items.declaration(roots)),
|
||||||
},
|
},
|
||||||
Self::Record(fields) => TypeExpr::Record {
|
Self::Record(fields) => TypeExpr::Record {
|
||||||
fields: fields
|
fields: fields
|
||||||
@@ -209,8 +232,8 @@ impl Shape {
|
|||||||
.map(|input| FieldDeclaration {
|
.map(|input| FieldDeclaration {
|
||||||
name: input.name.into(),
|
name: input.name.into(),
|
||||||
required: input.required,
|
required: input.required,
|
||||||
documentation: input.ty.documentation(),
|
documentation: input.ty.documentation(roots),
|
||||||
r#type: input.ty.declaration(),
|
r#type: input.ty.declaration(roots),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
@@ -220,7 +243,10 @@ impl Shape {
|
|||||||
.map(|case| UnionCase {
|
.map(|case| UnionCase {
|
||||||
name: case.name.into(),
|
name: case.name.into(),
|
||||||
documentation: None,
|
documentation: None,
|
||||||
payload: case.payload.as_ref().map(Self::declaration),
|
payload: case
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
|
.map(|payload| payload.declaration(roots)),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
@@ -350,16 +376,20 @@ fn insert(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReferenceKind {
|
impl ReferenceKind {
|
||||||
|
fn root<'a>(self, roots: &ReferenceRoots<'a>) -> &'a str {
|
||||||
|
match self {
|
||||||
|
Self::Ticket => roots.ticket,
|
||||||
|
Self::MergeRequest => roots.merge_request,
|
||||||
|
Self::Objective => roots.objective,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn identity<'a>(
|
fn identity<'a>(
|
||||||
self,
|
self,
|
||||||
path: &'a str,
|
path: &'a str,
|
||||||
roots: &ReferenceRoots<'_>,
|
roots: &ReferenceRoots<'_>,
|
||||||
) -> Result<&'a str, WipOperationError> {
|
) -> Result<&'a str, WipOperationError> {
|
||||||
let root = match self {
|
let root = self.root(roots);
|
||||||
Self::Ticket => roots.ticket,
|
|
||||||
Self::MergeRequest => roots.merge_request,
|
|
||||||
Self::Objective => roots.objective,
|
|
||||||
};
|
|
||||||
wip_protocol::validate_path(root).map_err(invalid)?;
|
wip_protocol::validate_path(root).map_err(invalid)?;
|
||||||
wip_protocol::validate_path(path).map_err(invalid)?;
|
wip_protocol::validate_path(path).map_err(invalid)?;
|
||||||
let leaf = path
|
let leaf = path
|
||||||
@@ -499,7 +529,7 @@ mod tests {
|
|||||||
destination: Destination::Flatten,
|
destination: Destination::Flatten,
|
||||||
}]);
|
}]);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
contract.declaration().parameters[0].r#type,
|
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||||
TypeExpr::Union { .. }
|
TypeExpr::Union { .. }
|
||||||
));
|
));
|
||||||
let arguments = fields([(
|
let arguments = fields([(
|
||||||
@@ -578,7 +608,7 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
Shape::Nullable(Box::new(Shape::String)),
|
Shape::Nullable(Box::new(Shape::String)),
|
||||||
)]);
|
)]);
|
||||||
let declaration = contract.declaration();
|
let declaration = contract.declaration(&ROOTS);
|
||||||
assert!(!declaration.parameters[0].required);
|
assert!(!declaration.parameters[0].required);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&declaration.parameters[0].r#type,
|
&declaration.parameters[0].r#type,
|
||||||
@@ -684,7 +714,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
contract.declaration().parameters[0].r#type,
|
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||||
TypeExpr::Union { .. }
|
TypeExpr::Union { .. }
|
||||||
));
|
));
|
||||||
for (value, expected_json, expected_reason) in [
|
for (value, expected_json, expected_reason) in [
|
||||||
@@ -743,7 +773,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
Shape::Reference(ReferenceKind::Objective),
|
Shape::Reference(ReferenceKind::Objective),
|
||||||
)]);
|
)]);
|
||||||
let parameter = &contract.declaration().parameters[0];
|
let parameter = &contract.declaration(&ROOTS).parameters[0];
|
||||||
assert_eq!(parameter.r#type, TypeExpr::Entry);
|
assert_eq!(parameter.r#type, TypeExpr::Entry);
|
||||||
assert!(
|
assert!(
|
||||||
parameter
|
parameter
|
||||||
@@ -814,7 +844,7 @@ mod tests {
|
|||||||
("change", text("/workspace/changes/MR-9")),
|
("change", text("/workspace/changes/MR-9")),
|
||||||
]);
|
]);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
contract.declaration().parameters[0].r#type,
|
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||||
TypeExpr::Entry
|
TypeExpr::Entry
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2040,8 +2040,14 @@ pub fn mount_workspace_http_ticket_wip(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||||
|
|
||||||
|
let roots = crate::feature::builtin::native_contract::ReferenceRoots {
|
||||||
|
ticket: &collection_route,
|
||||||
|
merge_request: &merge_request_route,
|
||||||
|
objective: &objective_route,
|
||||||
|
};
|
||||||
let collection_descriptor = ticket_descriptor(
|
let collection_descriptor = ticket_descriptor(
|
||||||
&collection_tools,
|
&collection_tools,
|
||||||
|
&roots,
|
||||||
"Search and create authoritative Workspace Tickets",
|
"Search and create authoritative Workspace Tickets",
|
||||||
"Search results remain bounded and carry canonical item paths. Operations are exactly the Ticket tools enabled for this Worker.",
|
"Search results remain bounded and carry canonical item paths. Operations are exactly the Ticket tools enabled for this Worker.",
|
||||||
)?;
|
)?;
|
||||||
@@ -2077,6 +2083,7 @@ pub fn mount_workspace_http_ticket_wip(
|
|||||||
|
|
||||||
let item_descriptor = ticket_descriptor(
|
let item_descriptor = ticket_descriptor(
|
||||||
&item_tools,
|
&item_tools,
|
||||||
|
&roots,
|
||||||
"Read and operate on the Ticket bound to this object route",
|
"Read and operate on the Ticket bound to this object route",
|
||||||
"The subject Ticket identity comes exclusively from the target route. Separate relation and dependency targets remain explicit operation inputs.",
|
"The subject Ticket identity comes exclusively from the target route. Separate relation and dependency targets remain explicit operation inputs.",
|
||||||
)?;
|
)?;
|
||||||
@@ -2593,12 +2600,13 @@ fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec<u8> {
|
|||||||
|
|
||||||
fn ticket_descriptor(
|
fn ticket_descriptor(
|
||||||
tools: &[NativeTicketTool],
|
tools: &[NativeTicketTool],
|
||||||
|
roots: &crate::feature::builtin::native_contract::ReferenceRoots<'_>,
|
||||||
summary: &str,
|
summary: &str,
|
||||||
details: &str,
|
details: &str,
|
||||||
) -> Result<InterfaceDescriptor, WipMountError> {
|
) -> Result<InterfaceDescriptor, WipMountError> {
|
||||||
let operations = tools
|
let operations = tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(ticket_operation_declaration)
|
.map(|tool| ticket_operation_declaration(tool, roots))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(InterfaceDescriptor {
|
Ok(InterfaceDescriptor {
|
||||||
format: INTERFACE_FORMAT_V1.into(),
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
@@ -2613,8 +2621,9 @@ fn ticket_descriptor(
|
|||||||
|
|
||||||
fn ticket_operation_declaration(
|
fn ticket_operation_declaration(
|
||||||
tool: &NativeTicketTool,
|
tool: &NativeTicketTool,
|
||||||
|
roots: &crate::feature::builtin::native_contract::ReferenceRoots<'_>,
|
||||||
) -> Result<OperationDeclaration, WipMountError> {
|
) -> Result<OperationDeclaration, WipMountError> {
|
||||||
Ok(native::contract(tool.projection.operation).declaration())
|
Ok(native::contract(tool.projection.operation).declaration(roots))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_failure(code: ProtocolErrorCode, message: impl Into<String>) -> WipOperationError {
|
fn protocol_failure(code: ProtocolErrorCode, message: impl Into<String>) -> WipOperationError {
|
||||||
@@ -3287,7 +3296,17 @@ mod tests {
|
|||||||
let client = decision_client(200, TicketState::Closed);
|
let client = decision_client(200, TicketState::Closed);
|
||||||
let tools =
|
let tools =
|
||||||
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
|
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
|
||||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
let descriptor = ticket_descriptor(
|
||||||
|
&tools,
|
||||||
|
&crate::feature::builtin::native_contract::ReferenceRoots {
|
||||||
|
ticket: "/tickets",
|
||||||
|
merge_request: "/merge-requests",
|
||||||
|
objective: "/objectives",
|
||||||
|
},
|
||||||
|
"Ticket",
|
||||||
|
"bound",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let case_update = descriptor
|
let case_update = descriptor
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
@@ -3405,6 +3424,11 @@ mod tests {
|
|||||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
|
&crate::feature::builtin::native_contract::ReferenceRoots {
|
||||||
|
ticket: "/tickets",
|
||||||
|
merge_request: "/merge-requests",
|
||||||
|
objective: "/objectives",
|
||||||
|
},
|
||||||
"items",
|
"items",
|
||||||
"route bound",
|
"route bound",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -606,7 +606,7 @@ pub(super) fn contract(op: &str) -> Contract {
|
|||||||
"query_orchestration_plans",
|
"query_orchestration_plans",
|
||||||
"Read bounded project-level plan records; planning history is not execution authority.",
|
"Read bounded project-level plan records; planning history is not execution authority.",
|
||||||
vec![
|
vec![
|
||||||
p("kind", false, plan_kind()),
|
rename("kind", "relation_kind", false, plan_kind()),
|
||||||
p("limit", false, Shape::Integer),
|
p("limit", false, Shape::Integer),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -234,7 +234,11 @@ fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let declaration = contract("orchestrator_assign").declaration();
|
let declaration = contract("orchestrator_assign").declaration(&ReferenceRoots {
|
||||||
|
ticket: "/cases",
|
||||||
|
merge_request: "/changes",
|
||||||
|
objective: "/goals",
|
||||||
|
});
|
||||||
assert!(
|
assert!(
|
||||||
declaration
|
declaration
|
||||||
.parameters
|
.parameters
|
||||||
|
|||||||
@@ -255,3 +255,308 @@ async fn large_ticket_interface_is_explicitly_bounded_and_selected_operation_rem
|
|||||||
assert_eq!(client.requests().len(), 1);
|
assert_eq!(client.requests().len(), 1);
|
||||||
client.assert_authorizations_consumed();
|
client.assert_authorizations_consumed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The transport adapter applies the real SQLite query to an isolated multi-kind fixture.
|
||||||
|
// Authorization is scripted; the native conversion, shared command, HTTP request and persisted
|
||||||
|
// filter semantics are production paths. This does not claim Server authentication coverage.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_plan_kind_reaches_persisted_query_through_inspect_and_dispatch() {
|
||||||
|
use crate::worker::{
|
||||||
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
||||||
|
};
|
||||||
|
use ticket::{
|
||||||
|
AcceptedOrchestrationPlan, NewOrchestrationPlanRecord, NewTicket, OrchestrationPlanKind,
|
||||||
|
SqliteTicketBackend, TicketBackend as _, TicketIdOrSlug,
|
||||||
|
};
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PersistedPlans {
|
||||||
|
backend: SqliteTicketBackend,
|
||||||
|
ticket: String,
|
||||||
|
requests: Mutex<Vec<Json>>,
|
||||||
|
}
|
||||||
|
impl WorkspaceClient for PersistedPlans {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("plan-fixture")
|
||||||
|
}
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"persisted-plan-filter"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
if request.path.ends_with("/authorization/check") {
|
||||||
|
let check: server_api::authorization::OperationAuthorizationRequest =
|
||||||
|
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(check.operation, "ticket.orchestration_plan.query");
|
||||||
|
assert_eq!(
|
||||||
|
check.resource,
|
||||||
|
server_api::authorization::PermissionResource::Ticket {
|
||||||
|
workspace_id: "plan-fixture".into(),
|
||||||
|
ticket_id: self.ticket.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: "{}".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
request.path,
|
||||||
|
"/api/w/plan-fixture/tickets/orchestration-plans/search"
|
||||||
|
);
|
||||||
|
let body: Json = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||||
|
let records = self
|
||||||
|
.backend
|
||||||
|
.query_orchestration_plan_records(
|
||||||
|
serde_json::from_value(body["ticket"].clone()).unwrap(),
|
||||||
|
serde_json::from_value(body["kind"].clone()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
self.requests.lock().unwrap().push(body);
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&records).unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let backend =
|
||||||
|
SqliteTicketBackend::open(temp.path().join("plans.sqlite3"), "plan-fixture").unwrap();
|
||||||
|
let id = backend
|
||||||
|
.create(NewTicket::new("Synthetic native plan fixture"))
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
for kind in [
|
||||||
|
OrchestrationPlanKind::AcceptedPlan,
|
||||||
|
OrchestrationPlanKind::WaitingCapacityNote,
|
||||||
|
] {
|
||||||
|
backend
|
||||||
|
.add_orchestration_plan_record(
|
||||||
|
TicketIdOrSlug::Id(id.clone()),
|
||||||
|
NewOrchestrationPlanRecord {
|
||||||
|
kind,
|
||||||
|
related_ticket: None,
|
||||||
|
note: Some("Synthetic plan context".into()),
|
||||||
|
author: None,
|
||||||
|
accepted_plan: (kind == OrchestrationPlanKind::AcceptedPlan).then(|| {
|
||||||
|
AcceptedOrchestrationPlan {
|
||||||
|
summary: "Synthetic accepted plan".into(),
|
||||||
|
branch: None,
|
||||||
|
worktree: None,
|
||||||
|
role_plan: None,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let client = Arc::new(PersistedPlans {
|
||||||
|
backend,
|
||||||
|
ticket: id.clone(),
|
||||||
|
requests: Mutex::new(vec![]),
|
||||||
|
});
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
let namespace = registry.allocate_namespace("ticket", "cases").unwrap();
|
||||||
|
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||||
|
&mut registry,
|
||||||
|
client.clone(),
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess {
|
||||||
|
workflow: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
&namespace,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let runtime = WipRuntime::from_mounts(registry, "plans".into()).unwrap();
|
||||||
|
let path = format!("/cases/{id}");
|
||||||
|
let object: Json = serde_json::from_str(
|
||||||
|
&runtime
|
||||||
|
.inspect(path.clone(), false)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let shown = &object["interfaces"][0];
|
||||||
|
let definition: Json = serde_json::from_str(
|
||||||
|
&runtime
|
||||||
|
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let signature = definition["interface_signature"].as_str().unwrap();
|
||||||
|
assert!(signature.contains("query_orchestration_plans("));
|
||||||
|
assert!(signature.contains("kind?:"));
|
||||||
|
assert!(signature.contains("accepted_plan"));
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||||
|
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||||
|
};
|
||||||
|
for (arguments, expected, kind) in [
|
||||||
|
(json!({}), 2, None),
|
||||||
|
(json!({"kind":"accepted_plan"}), 1, Some("accepted_plan")),
|
||||||
|
(
|
||||||
|
json!({"kind":"waiting_capacity_note","limit":1}),
|
||||||
|
1,
|
||||||
|
Some("waiting_capacity_note"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
runtime.inspect(path.clone(), true).await.unwrap();
|
||||||
|
let result = runtime
|
||||||
|
.invoke(
|
||||||
|
path.clone(),
|
||||||
|
reference.clone(),
|
||||||
|
"query_orchestration_plans".into(),
|
||||||
|
arguments,
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result: Json = serde_json::from_str(&result.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(result["count"], expected);
|
||||||
|
assert_eq!(
|
||||||
|
result["records"].as_array().unwrap().len(),
|
||||||
|
expected as usize
|
||||||
|
);
|
||||||
|
if let Some(kind) = kind {
|
||||||
|
assert_eq!(result["records"][0]["kind"], kind);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
client.requests.lock().unwrap().last().unwrap()["kind"],
|
||||||
|
kind.map(Json::from).unwrap_or(Json::Null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_inspect_explains_unpublished_objective_and_mr_input_roots() {
|
||||||
|
let client = Arc::new(
|
||||||
|
ScriptedWorkspaceClient::new([
|
||||||
|
crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({"items":[],"page":{"next_cursor":null,"has_more":false}}).to_string(),
|
||||||
|
},
|
||||||
|
crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({"items":[],"next_cursor":null,"source_checks":[]}).to_string(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.with_authorizations([
|
||||||
|
expected_authorization("ticket.query", "workspace", None),
|
||||||
|
expected_authorization(
|
||||||
|
"notification.diagnostics.read",
|
||||||
|
"merge_request",
|
||||||
|
Some(("merge_request_id", "MR-42")),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
registry
|
||||||
|
.allocate_namespace("objective", "hidden-goals")
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.allocate_namespace("merge-request", "hidden-changes")
|
||||||
|
.unwrap();
|
||||||
|
let namespace = registry.allocate_namespace("ticket", "cases").unwrap();
|
||||||
|
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||||
|
&mut registry,
|
||||||
|
client.clone(),
|
||||||
|
crate::feature::builtin::ticket::TicketFeatureAccess::read_only(),
|
||||||
|
None,
|
||||||
|
&namespace,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let runtime = WipRuntime::from_mounts(registry, "unpublished-inputs".into()).unwrap();
|
||||||
|
let tree = runtime.tree("/".into(), 1, false).await.unwrap();
|
||||||
|
assert!(!tree.content.unwrap().contains("hidden-"));
|
||||||
|
let object: Json = serde_json::from_str(
|
||||||
|
&runtime
|
||||||
|
.inspect("/cases".into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let shown = &object["interfaces"][0];
|
||||||
|
let definition: Json = serde_json::from_str(
|
||||||
|
&runtime
|
||||||
|
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let signature = definition["interface_signature"].as_str().unwrap();
|
||||||
|
let root = |domain| -> String {
|
||||||
|
let prefix = format!("{domain} Entry: collection root ");
|
||||||
|
let doc = signature
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||||
|
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||||
|
.find(|text| text.starts_with(&prefix))
|
||||||
|
.unwrap();
|
||||||
|
let encoded = doc
|
||||||
|
.strip_prefix(&prefix)
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
serde_json::from_str(encoded).unwrap()
|
||||||
|
};
|
||||||
|
let objective = root("Objective");
|
||||||
|
let mr = root("Merge Request");
|
||||||
|
assert_eq!(objective, "/hidden-goals");
|
||||||
|
assert_eq!(mr, "/hidden-changes");
|
||||||
|
let reference = InterfaceReference {
|
||||||
|
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||||
|
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||||
|
};
|
||||||
|
for (operation, arguments) in [
|
||||||
|
(
|
||||||
|
"query",
|
||||||
|
json!({"filter":{"objective":format!("{objective}/O-42")}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"notification_diagnostics",
|
||||||
|
json!({"filter":{"merge_request":format!("{mr}/MR-42")}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
runtime
|
||||||
|
.invoke(
|
||||||
|
"/cases".into(),
|
||||||
|
reference.clone(),
|
||||||
|
operation.into(),
|
||||||
|
arguments,
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
client.assert_authorizations_consumed();
|
||||||
|
let requests = client.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let query: Json = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(query["linked_objective_id"], "O-42");
|
||||||
|
assert!(
|
||||||
|
requests[1]
|
||||||
|
.path
|
||||||
|
.ends_with("notification-diagnostics?merge_request_id=MR-42")
|
||||||
|
);
|
||||||
|
for path in [objective, mr] {
|
||||||
|
assert!(
|
||||||
|
runtime.inspect(path, false).await.is_err(),
|
||||||
|
"input metadata does not publish Objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user