feat: expose read-only notification routing and delivery diagnostics
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
|
||||
mod assignment_public;
|
||||
mod assignment_tracking;
|
||||
mod notification_diagnostics;
|
||||
mod tags;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -486,6 +487,7 @@ fn workspace_ticket_decision_definition(
|
||||
fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
match name {
|
||||
"TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(),
|
||||
"NotificationDiagnostics" => notification_diagnostics::DESCRIPTION.into(),
|
||||
name if assignment_public::TOOL_NAMES.contains(&name) => {
|
||||
assignment_public::description(name).into()
|
||||
}
|
||||
@@ -642,6 +644,7 @@ impl TicketFeatureAccess {
|
||||
|| (*name == "TicketAssignmentAnswer" && self.thread)
|
||||
}),
|
||||
)
|
||||
.chain(std::iter::once(notification_diagnostics::NAME))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -665,6 +668,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"NotificationDiagnostics",
|
||||
];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
@@ -701,6 +705,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
"NotificationDiagnostics",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -727,6 +732,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
"NotificationDiagnostics",
|
||||
];
|
||||
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
@@ -1631,6 +1637,12 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
identity_field: Some("ticket"),
|
||||
mutating: false,
|
||||
},
|
||||
"NotificationDiagnostics" => NativeTicketOperation {
|
||||
operation: "notification_diagnostics",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: false,
|
||||
},
|
||||
"TicketAssignmentDiagnostics" => NativeTicketOperation {
|
||||
operation: "assignment_diagnostics",
|
||||
surface: NativeTicketSurface::Item,
|
||||
@@ -1770,6 +1782,9 @@ pub(crate) fn enabled_ticket_definitions(
|
||||
definitions.push(assignment_public::definition(client.clone(), kind));
|
||||
}
|
||||
}
|
||||
if allowed.contains(¬ification_diagnostics::NAME) {
|
||||
definitions.push(notification_diagnostics::definition(client.clone()));
|
||||
}
|
||||
definitions
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Host-bound read-only notification evidence, shared by normal Tools and WIP.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
use super::*;
|
||||
use crate::worker::WorkspaceServerOperation;
|
||||
|
||||
pub(super) const NAME: &str = "NotificationDiagnostics";
|
||||
pub(super) const DESCRIPTION: &str = "Read bounded, paginated saved notification routing and delivery evidence within the current Workspace. Filter by Ticket, MR, Job, source event, assignment, delivery or exact source identity; continue with next_cursor and unchanged filters (limit 1..100). Distinguishes awaiting routing, suppression, deferred delivery, rejection, unknown acceptance and Runtime acceptance. Acceptance does not prove model processing or successful work. Historical evidence and retry_candidate are not execution authority. This read never routes, sends, assigns, starts/restores Workers or changes case/handling; no notification bodies or binding credentials are exposed.";
|
||||
|
||||
struct DiagnosticsTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Tool for DiagnosticsTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Notification diagnostics require a Backend Workspace; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let query: server_api::NotificationDiagnosticsQuery =
|
||||
serde_json::from_str(input).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
|
||||
if !(1..=100).contains(&query.limit.unwrap_or(50)) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be between 1 and 100".into(),
|
||||
));
|
||||
}
|
||||
for value in [
|
||||
&query.ticket,
|
||||
&query.merge_request_id,
|
||||
&query.job_id,
|
||||
&query.source_event_id,
|
||||
&query.assignment_id,
|
||||
&query.delivery_id,
|
||||
&query.source_id,
|
||||
&query.cursor,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Notification filters must be nonempty, control-free and at most 512 bytes"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let response = self
|
||||
.client
|
||||
.execute_server_operation(WorkspaceServerOperation::NotificationDiagnostics { query })
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
if !response.is_success() {
|
||||
// Do not expose an arbitrary error body from upstream.
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Notification diagnostics failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
if response.body.len() > 1024 * 1024 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Notification response exceeds 1 MiB".into(),
|
||||
));
|
||||
}
|
||||
let page: server_api::NotificationDiagnosticsPage = serde_json::from_str(&response.body)
|
||||
.map_err(|_| {
|
||||
ToolError::ExecutionFailed("Invalid notification diagnostics response".into())
|
||||
})?;
|
||||
if page.items.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Notification response exceeds 100 items".into(),
|
||||
));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: NAME.into(),
|
||||
content: Some(
|
||||
serde_json::to_string(&page).map_err(|e| ToolError::Internal(e.to_string()))?,
|
||||
),
|
||||
attachments: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(NAME)
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(json!(schemars::schema_for!(
|
||||
server_api::NotificationDiagnosticsQuery
|
||||
))),
|
||||
Arc::new(DiagnosticsTool {
|
||||
client: client.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
#[derive(Debug)]
|
||||
struct Client {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
impl WorkspaceClient for Client {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"notification-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: self.status,
|
||||
body: self.body.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn notification_tool_and_wip_share_bounded_read_contract() {
|
||||
let client = Arc::new(Client {
|
||||
requests: Mutex::new(vec![]),
|
||||
status: 200,
|
||||
body: json!({"items":[],"next_cursor":null,"source_checks":[]}).to_string(),
|
||||
});
|
||||
let (meta, tool) = definition(client.clone())();
|
||||
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
let input = json!({"ticket":"T-755","merge_request_id":"mr/1?","job_id":"job","cursor":"notification-source:4","limit":10});
|
||||
assert!(schema.is_valid(&input));
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.starts_with("/api/w/workspace/notification-diagnostics?")
|
||||
);
|
||||
assert!(requests[0].path.contains("merge_request_id=mr%2F1%3F"));
|
||||
assert!(requests[0].body.is_none());
|
||||
drop(requests);
|
||||
for bad in [
|
||||
json!({"limit":0}),
|
||||
json!({"limit":101}),
|
||||
json!({"job_id":""}),
|
||||
json!({"source_event_id":"x".repeat(513)}),
|
||||
json!({"worker_id":"forged"}),
|
||||
json!({"workspace_id":"other"}),
|
||||
] {
|
||||
assert!(
|
||||
tool.execute(&bad.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
let projection = native_ticket_operation(NAME).unwrap();
|
||||
assert_eq!(projection.operation, "notification_diagnostics");
|
||||
assert!(!projection.mutating);
|
||||
assert!(projection.identity_field.is_none());
|
||||
for access in [
|
||||
TicketFeatureAccess::read_only(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
TicketFeatureAccess::workflow(),
|
||||
] {
|
||||
assert!(access.tool_names().contains(&NAME));
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn notification_tool_rejects_raw_or_undeclared_backend_content() {
|
||||
for (status, body) in [
|
||||
(403, "SECRET-ERROR".to_string()),
|
||||
(
|
||||
200,
|
||||
json!({"items":[],"next_cursor":null,"source_checks":[],"credential":"SECRET"})
|
||||
.to_string(),
|
||||
),
|
||||
(200, "not-json-SECRET".into()),
|
||||
] {
|
||||
let (_, tool) = definition(Arc::new(Client {
|
||||
requests: Mutex::new(vec![]),
|
||||
status,
|
||||
body,
|
||||
}))();
|
||||
let error = tool
|
||||
.execute("{}", ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("SECRET"));
|
||||
}
|
||||
}
|
||||
@@ -746,6 +746,9 @@ pub enum WorkspaceServerOperation {
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentsQuery,
|
||||
},
|
||||
NotificationDiagnostics {
|
||||
query: server_api::NotificationDiagnosticsQuery,
|
||||
},
|
||||
TicketAssignmentDiagnostics {
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentDiagnosticsQuery,
|
||||
@@ -906,6 +909,13 @@ fn workspace_server_operation_request(
|
||||
&query,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::NotificationDiagnostics { query } => {
|
||||
workspace_server_query_request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/notification-diagnostics"),
|
||||
&query,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
|
||||
workspace_server_query_request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
|
||||
Reference in New Issue
Block a user