From b71fc9ee5489c3305550a04251a28df9e97a287d Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 15 Oct 2026 17:55:54 +0900 Subject: [PATCH] feat: expose read-only notification routing and delivery diagnostics --- crates/server-api/src/lib.rs | 12 + crates/server-api/src/notification.rs | 82 ++++ .../src/repository_openapi_typescript.rs | 7 + crates/worker-runtime/src/worker_source.rs | 18 + crates/worker/src/feature/builtin/ticket.rs | 15 + .../ticket/notification_diagnostics.rs | 99 +++++ .../ticket/notification_diagnostics/tests.rs | 104 ++++++ crates/worker/src/worker.rs | 10 + .../src/notification_diagnostics.rs | 238 ++++++++++++ .../src/notification_diagnostics_tests.rs | 99 +++++ crates/workspace-server/src/server.rs | 23 ++ .../src/server_assignment_delivery_tests.rs | 29 ++ crates/workspace-server/src/store.rs | 15 + openapi/server-api.json | 351 +++++++++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 10 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 19 files changed, 1114 insertions(+), 6 deletions(-) create mode 100644 crates/server-api/src/notification.rs create mode 100644 crates/worker/src/feature/builtin/ticket/notification_diagnostics.rs create mode 100644 crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs create mode 100644 crates/workspace-server/src/notification_diagnostics.rs create mode 100644 crates/workspace-server/src/notification_diagnostics_tests.rs diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 0d9f2c715..792f3e367 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -7,6 +7,8 @@ pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; mod drive; +mod notification; +pub use notification::*; pub mod repository_openapi_typescript; pub use drive::*; mod workspace_config; @@ -366,6 +368,8 @@ impl_openapi_schema!( TicketAssignmentsPage, TicketAssignmentAnswerRequest, TicketAssignmentAnswerResponse, + NotificationDiagnosticsQuery, + NotificationDiagnosticsPage, TicketAssignmentDiagnosticsQuery, TicketAssignmentDiagnosticsPage, TicketQueryRequest, @@ -3241,6 +3245,14 @@ pub trait ServerApi { #[query] query: TicketAssignmentsQuery, ) -> Result; + #[get("/api/w/{workspace_id}/notification-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn notification_diagnostics( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: NotificationDiagnosticsQuery, + ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_diagnostics( &self, diff --git a/crates/server-api/src/notification.rs b/crates/server-api/src/notification.rs new file mode 100644 index 000000000..fb26518a8 --- /dev/null +++ b/crates/server-api/src/notification.rs @@ -0,0 +1,82 @@ +//! Read-only notification evidence. Acceptance is not model processing or execution authority. +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NotificationDiagnosticsQuery { + pub ticket: Option, + pub merge_request_id: Option, + pub job_id: Option, + pub source_event_id: Option, + pub assignment_id: Option, + pub delivery_id: Option, + /// Exact source identity for a single-record lookup. + pub source_id: Option, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NotificationDiagnosticStage { + Historical, + AwaitingRouting, + Suppressed, + Deferred, + Pending, + Reserved, + RequestPersisted, + RuntimeRejected, + AcceptanceUnknown, + DeliveryFailed, + RuntimeAccepted, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NotificationDiagnostic { + pub source_id: String, + pub source_event_id: String, + pub event_kind: String, + pub ticket: Option, + pub merge_request_id: Option, + pub job_id: Option, + pub recipient_role: String, + pub assignment_id: Option, + /// Current assignment identity only; null for fixed-worker or absent recipients. + pub recipient_current: Option, + pub disposition: Option, + pub reason: Option, + pub purpose: Option, + pub delivery_id: Option, + pub stage: NotificationDiagnosticStage, + pub target_runtime_id: Option, + pub target_worker_id: Option, + pub last_failure_code: Option, + pub last_failure_at: Option, + /// Saved delivery candidate, NOT permission to send. Live recipient and binding checks remain required. + pub retry_candidate: bool, + /// No model-processing evidence is currently collected by this read contract. + pub model_processing_confirmed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NotificationSourceCheck { + pub resource_kind: String, + pub reference: String, + /// Whether an exact durable origin is present; null means that reference form was not verified. + pub origin_present: Option, + /// Whether the origin has any captured source (independent of pagination/recipient filters). + /// A present origin with false here is unconnected, not intentionally suppressed. + pub captured: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NotificationDiagnosticsPage { + pub items: Vec, + pub next_cursor: Option, + pub source_checks: Vec, +} diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 67210a88f..388eed19d 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -536,6 +536,13 @@ fn validate_ticket_browser_operations( request: None, successes: &[("200", "TicketAssignmentsPage")], }, + OperationContract { + path: "/api/w/{workspace_id}/notification-diagnostics", + method: "get", + operation_id: "notification_diagnostics", + request: None, + successes: &[("200", "NotificationDiagnosticsPage")], + }, OperationContract { path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", method: "get", diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 47cc144db..30cacccec 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -640,6 +640,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { .await, ) } + WorkspaceServerOperation::NotificationDiagnostics { query } => { + generated_workspace_response( + client.notification_diagnostics(workspace_id, query).await, + ) + } WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query, @@ -955,6 +960,19 @@ mod tests { serde_json::json!({"workspace_id":"workspace-a", "ticket_id":"T-1", "assignment":null}); let answer: server_api::TicketAssignmentAnswerRequest = serde_json::from_value(serde_json::json!({ "assignment_id": "delegation", "expected_last_event_id": "observed", "operation_key": "answer-op", "body": "User answer\ncontinued" })).unwrap(); let cases = vec![ + ( + WorkspaceServerOperation::NotificationDiagnostics { + query: server_api::NotificationDiagnosticsQuery { + ticket: Some("T-1".into()), + source_event_id: Some("ticket-event:1".into()), + limit: Some(1), + ..Default::default() + }, + }, + "GET", + "/api/w/workspace-a/notification-diagnostics?ticket=T-1&source_event_id=ticket-event%3A1&limit=1", + serde_json::json!({"items":[],"next_cursor":null,"source_checks":[]}), + ), ( WorkspaceServerOperation::TicketAssignmentSet { ticket_id: "T-1".into(), diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index e8fc7e1dc..7892cb644 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -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 { 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 } diff --git a/crates/worker/src/feature/builtin/ticket/notification_diagnostics.rs b/crates/worker/src/feature/builtin/ticket/notification_diagnostics.rs new file mode 100644 index 000000000..51e49455e --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/notification_diagnostics.rs @@ -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, +} +#[async_trait] +impl Tool for DiagnosticsTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + 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) -> 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, + ) + }) +} diff --git a/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs b/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs new file mode 100644 index 000000000..f7d225498 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; +#[derive(Debug)] +struct Client { + requests: Mutex>, + 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 { + 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")); + } +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index e2d8008a0..6e2a08d12 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -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, diff --git a/crates/workspace-server/src/notification_diagnostics.rs b/crates/workspace-server/src/notification_diagnostics.rs new file mode 100644 index 000000000..51d168411 --- /dev/null +++ b/crates/workspace-server/src/notification_diagnostics.rs @@ -0,0 +1,238 @@ +//! Read-only projection of source facts and saved routing/delivery evidence. +//! No policy reimplementation: absent decisions stay awaiting_routing, never suppression. +use super::*; +use server_api::{ + NotificationDiagnostic, NotificationDiagnosticStage as Stage, NotificationDiagnosticsPage, + NotificationDiagnosticsQuery, +}; + +fn source_checks( + conn: &Connection, + workspace: &str, + query: &NotificationDiagnosticsQuery, +) -> Result> { + let mut checks = Vec::new(); + if let Some(id) = &query.merge_request_id { + let origin: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2)",params![workspace,id],|r|r.get(0))?; + let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources WHERE workspace_id=?1 AND json_extract(facts_json,'$.merge_request_id')=?2)",params![workspace,id],|r|r.get(0))?; + checks.push(server_api::NotificationSourceCheck { + resource_kind: "merge_request_events".into(), + reference: id.clone(), + origin_present: Some(origin), + captured, + }); + } + if let Some(id) = &query.job_id { + let origin: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM backend_jobs WHERE workspace_id=?1 AND job_id=?2 AND result_digest IS NOT NULL)",params![workspace,id],|r|r.get(0))?; + let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources WHERE workspace_id=?1 AND COALESCE(json_extract(facts_json,'$.job_id'),json_extract(facts_json,'$.backend_job.job_id'))=?2)",params![workspace,id],|r|r.get(0))?; + checks.push(server_api::NotificationSourceCheck { + resource_kind: "job_result".into(), + reference: id.clone(), + origin_present: Some(origin), + captured, + }); + } + if let Some(id) = &query.source_event_id { + let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources s LEFT JOIN workspace_resource_keys k ON k.workspace_id=s.workspace_id AND k.resource_kind='ticket' AND k.resource_id=s.ticket_id WHERE s.workspace_id=?1 AND s.input_event_id=?2 AND (?3 IS NULL OR s.ticket_id=?3 OR k.resource_key=?3))",params![workspace,id,query.ticket],|r|r.get(0))?; + let origin = if captured { + Some(true) + } else if let (Some(ticket), Some(index)) = ( + &query.ticket, + id.strip_prefix("ticket-event:") + .and_then(|v| v.parse::().ok()), + ) { + Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_events e LEFT JOIN workspace_resource_keys k ON k.workspace_id=e.workspace_id AND k.resource_kind='ticket' AND k.resource_id=e.ticket_id WHERE e.workspace_id=?1 AND (e.ticket_id=?2 OR k.resource_key=?2) AND e.event_index=?3)",params![workspace,ticket,index],|r|r.get(0))?) + } else { + None + }; + checks.push(server_api::NotificationSourceCheck { + resource_kind: "source_event".into(), + reference: id.clone(), + origin_present: origin, + captured, + }); + } + Ok(checks) +} + +fn sequence(value: &str) -> Result { + value + .strip_prefix("notification-source:") + .and_then(|n| n.parse::().ok()) + .filter(|n| *n > 0) + .ok_or_else(|| Error::InvalidInput("Invalid notification source cursor".into())) +} +fn bounded(value: Option) -> Option { + value.map(|s| s.chars().take(512).collect()) +} + +pub(super) fn page( + store: &SqliteWorkspaceStore, + workspace: &str, + query: &NotificationDiagnosticsQuery, +) -> Result { + store.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + page_with_connection(&tx, workspace, query) + }) +} + +pub(super) fn page_with_connection( + conn: &Connection, + workspace: &str, + query: &NotificationDiagnosticsQuery, +) -> Result { + let limit = query.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err(Error::InvalidInput( + "Notification 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(Error::InvalidInput("Invalid notification filter".into())); + } + } + let after = query + .cursor + .as_deref() + .map(sequence) + .transpose()? + .unwrap_or(0); + let source = query.source_id.as_deref().map(sequence).transpose()?; + let filter = "s.workspace_id=?1 + AND (?2 IS NULL OR s.ticket_id=?2 OR tk.resource_key=?2) + AND (?3 IS NULL OR json_extract(s.facts_json,'$.merge_request_id')=?3) + AND (?4 IS NULL OR COALESCE(json_extract(s.facts_json,'$.job_id'),json_extract(s.facts_json,'$.backend_job.job_id'))=?4) + AND (?5 IS NULL OR s.input_event_id=?5) + AND (?6 IS NULL OR s.assignment_id=?6) + AND (?7 IS NULL OR d.delivery_id=?7) + AND (?8 IS NULL OR s.sequence=?8)"; + let joins = "FROM notification_sources s + LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence AND d.workspace_id=s.workspace_id + LEFT JOIN workspace_resource_keys tk ON tk.workspace_id=s.workspace_id AND tk.resource_kind='ticket' AND tk.resource_id=s.ticket_id"; + let bind = params![ + workspace, + query.ticket, + query.merge_request_id, + query.job_id, + query.source_event_id, + query.assignment_id, + query.delivery_id, + source, + after, + limit as i64 + 1 + ]; + // Validate the cursor against the same Workspace and filters, not a global row id. + if after > 0 { + let exists: bool = conn.query_row( + &format!("SELECT EXISTS(SELECT 1 {joins} WHERE {filter} AND s.sequence=?9)"), + &bind[..9], + |row| row.get(0), + )?; + if !exists { + return Err(Error::InvalidInput( + "Notification cursor does not belong to this query".into(), + )); + } + } + let sql = format!("SELECT s.sequence,s.input_event_id,s.kind,COALESCE(tk.resource_key,s.ticket_id), + json_extract(s.facts_json,'$.merge_request_id'),COALESCE(json_extract(s.facts_json,'$.job_id'),json_extract(s.facts_json,'$.backend_job.job_id')),s.role,NULLIF(s.assignment_id,''), + d.disposition,d.reason,d.purpose,d.delivery_id, + ar.delivery_id IS NOT NULL, + (r.delivery_id IS NOT NULL OR ap.delivery_id IS NOT NULL), + (a.delivery_id IS NOT NULL OR aa.delivery_id IS NOT NULL), + COALESCE(json_extract(a.target_json,'$.worker.runtime_id'),json_extract(aa.source_json,'$.runtime_id'),json_extract(r.target_json,'$.worker.runtime_id'),json_extract(ar.reserved_source_json,'$.runtime_id')), + COALESCE(json_extract(a.target_json,'$.worker.worker_id'),json_extract(aa.source_json,'$.worker_id'),json_extract(r.target_json,'$.worker.worker_id'),json_extract(ar.reserved_source_json,'$.worker_id')), + f.code,f.recorded_at, + CASE WHEN s.role IN ('orchestrator','worker') AND s.assignment_id!='' THEN EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=s.workspace_id AND c.ticket_id=s.ticket_id AND c.role=s.role AND c.assignment_id=s.assignment_id) END + {joins} + LEFT JOIN notification_requests r ON r.workspace_id=s.workspace_id AND r.delivery_id=d.delivery_id + LEFT JOIN notification_acknowledgements a ON a.workspace_id=s.workspace_id AND a.delivery_id=d.delivery_id + LEFT JOIN ticket_assignment_deliveries ar ON ar.workspace_id=s.workspace_id AND ar.delivery_id=d.delivery_id + LEFT JOIN ticket_assignment_delivery_requests ap ON ap.workspace_id=s.workspace_id AND ap.delivery_id=ar.delivery_id + LEFT JOIN ticket_assignment_delivery_acknowledgements aa ON aa.workspace_id=s.workspace_id AND aa.delivery_id=ar.delivery_id + LEFT JOIN notification_failures f ON f.sequence=(SELECT MAX(nf.sequence) FROM notification_failures nf WHERE nf.workspace_id=s.workspace_id AND nf.delivery_id=d.delivery_id) + WHERE {filter} AND s.sequence>?9 ORDER BY s.sequence LIMIT ?10"); + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query(bind)?; + let mut items = Vec::new(); + while let Some(row) = rows.next()? { + let disposition: Option = row.get(8)?; + let reserved: bool = row.get(12)?; + let prepared: bool = row.get(13)?; + let accepted: bool = row.get(14)?; + let failure: Option = row.get(17)?; + let recipient_current: Option = row.get(19)?; + let stage = if accepted { + Stage::RuntimeAccepted + } else if recipient_current == Some(false) { + Stage::Historical + } else if disposition.is_none() { + Stage::AwaitingRouting + } else if disposition.as_deref() == Some("suppress") { + Stage::Suppressed + } else if disposition.as_deref() == Some("defer") { + Stage::Deferred + } else if failure.as_deref() == Some("acceptance_unknown") { + Stage::AcceptanceUnknown + } else if failure.as_deref() == Some("runtime_rejected") { + Stage::RuntimeRejected + } else if failure.is_some() { + Stage::DeliveryFailed + } else if prepared { + Stage::RequestPersisted + } else if reserved { + Stage::Reserved + } else { + Stage::Pending + }; + let retry_candidate = !accepted + && recipient_current != Some(false) + && failure.as_deref() != Some("assignment_changed") + && disposition.as_deref() == Some("deliver"); + items.push(NotificationDiagnostic { + source_id: format!("notification-source:{}", row.get::<_, i64>(0)?), + source_event_id: bounded(row.get(1)?).unwrap_or_default(), + event_kind: bounded(row.get(2)?).unwrap_or_default(), + ticket: bounded(row.get(3)?), + merge_request_id: bounded(row.get(4)?), + job_id: bounded(row.get(5)?), + recipient_role: bounded(row.get(6)?).unwrap_or_default(), + assignment_id: bounded(row.get(7)?), + recipient_current, + disposition: bounded(disposition), + reason: bounded(row.get(9)?), + purpose: bounded(row.get(10)?), + delivery_id: bounded(row.get(11)?), + stage, + target_runtime_id: bounded(row.get(15)?), + target_worker_id: bounded(row.get(16)?), + last_failure_code: bounded(failure), + last_failure_at: bounded(row.get(18)?), + retry_candidate, + model_processing_confirmed: false, + }); + } + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more.then(|| items.last().expect("positive limit").source_id.clone()); + let source_checks = source_checks(conn, workspace, query)?; + Ok(NotificationDiagnosticsPage { + items, + next_cursor, + source_checks, + }) +} diff --git a/crates/workspace-server/src/notification_diagnostics_tests.rs b/crates/workspace-server/src/notification_diagnostics_tests.rs new file mode 100644 index 000000000..611b18c5d --- /dev/null +++ b/crates/workspace-server/src/notification_diagnostics_tests.rs @@ -0,0 +1,99 @@ +// Production Store and SQLite. No Runtime/model side effects are required for diagnostics. +#[tokio::test] +async fn notification_diagnostics_bounds_saved_reasons_without_returning_source_payloads() { + use server_api::NotificationDiagnosticsQuery as Q; + let (_dir,store)=tracking_store_fixture().await; + tracking_store_assign(&store,"a","assign-a"); + store.with_conn(|conn| { + conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,workspace_id,'bounded-reason','suppress',?1,'attention' FROM notification_sources WHERE assignment_id='a' LIMIT 1",["長".repeat(2000)])?; + Ok(()) + }).unwrap(); + let page=store.notification_diagnostics("w",&Q{delivery_id:Some("bounded-reason".into()),..Q::default()}).unwrap(); + assert_eq!(page.items[0].reason.as_ref().unwrap().chars().count(),512); + assert!(!serde_json::to_string(&page).unwrap().contains("facts_json")); +} +#[tokio::test] +async fn notification_diagnostics_detects_unconnected_origin_and_retains_history_without_pending_work() { + use server_api::{NotificationDiagnosticsQuery as Q,NotificationDiagnosticStage as S}; + let (_dir,store)=tracking_store_fixture().await; + tracking_store_assign(&store,"a","assign-a"); + let delivery=delivery_store_reserve_initial(&store,&tracking_store_source()); + store.clear_current_ticket_role_assignment("w","tracking",TicketAssignmentRole::Orchestrator,"a","clear-a","clear-a","owner","2",None).unwrap(); + let page=store.notification_diagnostics("w",&Q{delivery_id:Some(delivery.delivery_id),..Q::default()}).unwrap(); + assert_eq!(page.items[0].stage,S::Historical); + assert_eq!(page.items[0].recipient_current,Some(false)); + assert!(!page.items[0].retry_candidate); + // Simulate a missing producer adapter; a diagnostic read must not repair or hide it. + store.with_conn(|conn|{conn.execute_batch("DROP TRIGGER notification_ticket_event")?;Ok(())}).unwrap(); + delivery_store_external_event(&store,999,"comment",None); + store.with_conn(|conn|{conn.execute_batch("PRAGMA query_only=ON")?;Ok(())}).unwrap(); + let query=Q{ticket:Some("tracking".into()),source_event_id:Some("ticket-event:999".into()),..Q::default()}; + let page=store.notification_diagnostics("w",&query).unwrap(); + assert!(page.items.is_empty()); + assert_eq!(page.source_checks[0].origin_present,Some(true)); + assert!(!page.source_checks[0].captured); + let other=store.notification_diagnostics("other",&query).unwrap(); + assert_eq!(other.source_checks[0].origin_present,Some(false)); + assert!(!other.source_checks[0].captured); +} +#[tokio::test] +async fn notification_diagnostics_is_readonly_paginated_and_workspace_scoped() { + use server_api::{NotificationDiagnosticsQuery as Q, NotificationDiagnosticStage as S}; + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + delivery_store_answer(&store, "answer-a"); + let before = store.with_conn(|conn| Ok((conn.total_changes(),stored_fixture_rows(conn,"notification_decisions")))).unwrap(); + store.with_conn(|conn| { conn.execute_batch("PRAGMA query_only=ON")?; Ok(()) }).unwrap(); + let first = store.notification_diagnostics("w", &Q{limit:Some(1), ..Q::default()}).unwrap(); + assert_eq!(first.items.len(),1); + assert_eq!(first.items[0].stage,S::AwaitingRouting); + let next = store.notification_diagnostics("w", &Q{cursor:first.next_cursor.clone(), ..Q::default()}).unwrap(); + assert!(next.items.iter().all(|i| i.source_id != first.items[0].source_id)); + assert!(!next.items.is_empty()); + assert!(store.notification_diagnostics("other-workspace", &Q{cursor:Some(first.items[0].source_id.clone()), ..Q::default()}).is_err()); + assert!(store.notification_diagnostics("w", &Q{ticket:Some("missing".into()), cursor:Some(first.items[0].source_id.clone()), ..Q::default()}).is_err()); + for limit in [0,101] { assert!(store.notification_diagnostics("w", &Q{limit:Some(limit), ..Q::default()}).is_err()); } + assert!(store.notification_diagnostics("w", &Q{cursor:Some("not-a-source".into()), ..Q::default()}).is_err()); + assert_eq!(store.with_conn(|conn| Ok((conn.total_changes(),stored_fixture_rows(conn,"notification_decisions")))).unwrap(),before); + store.with_conn(|conn| { conn.execute_batch("PRAGMA query_only=OFF")?; Ok(()) }).unwrap(); +} + +#[tokio::test] +async fn notification_diagnostics_preserves_suppression_failure_and_acceptance_boundaries() { + use server_api::{NotificationDiagnosticsQuery as Q, NotificationDiagnosticStage as S}; + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store,"a","assign-a"); + let pending = store.list_pending_ticket_assignment_deliveries("w",100).unwrap(); + assert_eq!(pending.len(),1); + let query = Q{assignment_id:Some("a".into()), source_event_id:Some(pending[0].input_event_id.clone()), ..Q::default()}; + let read = || store.notification_diagnostics("w", &query).unwrap().items.remove(0); + assert_eq!(read().stage,S::Pending); + let source = tracking_store_source(); + let delivery = delivery_store_reserve_initial(&store,&source); + assert_eq!(read().stage,S::Reserved); + store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"PRIVATE-NOTIFICATION-CONTENT",&source).unwrap(); + assert_eq!(read().stage,S::RequestPersisted); + let key = NotificationDeliveryKey::AssignmentInput{assignment_id:delivery.assignment_id.clone(),input_event_id:delivery.input_event_id.clone()}; + store.record_notification_delivery_failure("w",&key,NotificationFailureCode::RuntimeRejected).unwrap(); + assert_eq!(read().stage,S::RuntimeRejected); + store.record_notification_delivery_failure("w",&key,NotificationFailureCode::AcceptanceUnknown).unwrap(); + assert_eq!(read().stage,S::AcceptanceUnknown); + store.ack_ticket_assignment_delivery("w",&delivery.delivery_id,&source).unwrap(); + let accepted = read(); + assert_eq!(accepted.stage,S::RuntimeAccepted); + assert!(!accepted.retry_candidate && !accepted.model_processing_confirmed); + assert_eq!(accepted.target_worker_id.as_deref(),Some(source.worker_id.as_str())); + let serialized = serde_json::to_string(&accepted).unwrap(); + assert!(!serialized.contains("PRIVATE-NOTIFICATION-CONTENT")); + assert!(!serialized.contains(&source.authentication_binding_id)); + let all = store.notification_diagnostics("w",&Q::default()).unwrap(); + assert!(all.items.iter().any(|i| i.stage==S::Suppressed && i.reason.as_deref()==Some("no_recipient"))); + store.with_conn_mut(|conn| { + conn.execute("INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) SELECT workspace_id,ticket_id,'future-event',role,assignment_id,'unregistered-event',facts_json FROM notification_sources WHERE assignment_id='a' LIMIT 1",[])?; + notification_routing::route_pending(conn,"w") + }).unwrap(); + let unknown = store.notification_diagnostics("w",&Q{source_event_id:Some("future-event".into()), ..Q::default()}).unwrap(); + assert_eq!(unknown.items[0].stage,S::Deferred); + assert_eq!(unknown.items[0].reason.as_deref(),Some("unknown_event")); + assert!(!unknown.items[0].retry_candidate); +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 941020e0d..7e6dba1de 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6862,6 +6862,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_summary_search( service.clone(), )) + .merge(server_api::server_api_axum::notification_diagnostics( + service.clone(), + )) .merge(server_api::server_api_axum::ticket_doctor(service.clone())) .merge(server_api::server_api_axum::ticket_relation_query( service.clone(), @@ -10796,6 +10799,26 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn notification_diagnostics( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::NotificationDiagnosticsQuery, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + let executor = api.store.db_executor(); + executor + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + api.store + .notification_diagnostics(&workspace_id, &query) + .map_err(ApiError::from) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + async fn ticket_case_update( &self, context: server_api::ServerRequestContext, diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index bf33cb92b..fcd84fc10 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -6,6 +6,26 @@ use super::*; use session_store::{LogEntry, Store, WorkerSessionStore}; use worker_runtime::execution as delivery_execution; +#[tokio::test] +async fn notification_diagnostics_http_auth_scope_and_readonly_evidence() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let ticket = delivery_ticket(&api, "Notification diagnostics auth fixture"); + delivery_assign(&api, ticket.id.as_str(), "diagnostics-assignment").await; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?ticket={}&limit=1",ticket.id.as_str()); + let page = delivery_http(&api,"GET",&path,None,StatusCode::OK).await; + assert_eq!(page["items"].as_array().unwrap().len(),1); + assert_eq!(page["items"][0]["model_processing_confirmed"],false); + assert!(page["items"][0].get("content").is_none()); + let again=delivery_http(&api,"GET",&path,None,StatusCode::OK).await; + assert_eq!(again,page); + let response=build_router(api.clone()).oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::UNAUTHORIZED); + delivery_http(&api,"GET","/api/w/other-workspace/notification-diagnostics",None,StatusCode::NOT_FOUND).await; + delivery_http(&api,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?limit=101"),None,StatusCode::BAD_REQUEST).await; +} + fn delivery_assignment_path(ticket: &str) -> String { format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator") } @@ -979,6 +999,10 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi "uncertain delivery changed request id or payload: {attempts:?}" ); assert_eq!(delivery_receipts(&api, &worker).len(), 1); + let unknown = api.store.notification_diagnostics(TEST_WORKSPACE_ID, &server_api::NotificationDiagnosticsQuery { + assignment_id: Some(assignment.into()), ..Default::default() + }).unwrap().items.into_iter().find(|item| item.stage == server_api::NotificationDiagnosticStage::AcceptanceUnknown).expect("saved unknown acceptance must remain visible while Runtime already has its receipt"); + assert!(!unknown.model_processing_confirmed); // Change mutable rendering context while the acknowledgement is unknown. // A new self-report is not a new input and cannot change the frozen retry. seed_worker_source_member(&api, &worker.runtime_id, &worker.worker_id); @@ -1038,6 +1062,11 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi } delivery_wait_idle(&reopened, &worker, 2).await; assert_eq!(delivery_receipts(&reopened, &worker).len(), 2); + let evidence = delivery_http(&reopened,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?source_id={}",unknown.source_id),None,StatusCode::OK).await; + assert_eq!(evidence["items"][0]["stage"],"runtime_accepted"); + assert_eq!(evidence["items"][0]["delivery_id"],json!(unknown.delivery_id)); + assert_eq!(evidence["items"][0]["model_processing_confirmed"],false); + assert_eq!(evidence["items"][0]["retry_candidate"],false); assert_eq!( delivery_commits(&reopened, &worker) .iter() diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 442a3b09e..28041e717 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -62,6 +62,8 @@ use crate::{Error, Result}; #[path = "assignment_delivery.rs"] mod assignment_delivery; +#[path = "notification_diagnostics.rs"] +mod notification_diagnostics; #[path = "notification_routing.rs"] mod notification_routing; pub use notification_routing::{ @@ -1897,6 +1899,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { delivery_id: &str, source: &TicketAssignmentTrackingSourceContext, ) -> Result<()>; + fn notification_diagnostics( + &self, + workspace_id: &str, + query: &server_api::NotificationDiagnosticsQuery, + ) -> Result; fn list_pending_ticket_worker_notifications( &self, workspace_id: &str, @@ -8041,6 +8048,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { None, ) } + fn notification_diagnostics( + &self, + workspace_id: &str, + query: &server_api::NotificationDiagnosticsQuery, + ) -> Result { + notification_diagnostics::page(self, workspace_id, query) + } fn list_pending_ticket_worker_notifications( &self, workspace_id: &str, @@ -15141,6 +15155,7 @@ mod tests { include!("assignment_tracking_tests.rs"); include!("assignment_delivery_tests.rs"); include!("notification_routing_tests.rs"); + include!("notification_diagnostics_tests.rs"); include!("store_workspace_config_tests.rs"); fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse { diff --git a/openapi/server-api.json b/openapi/server-api.json index 57106c86b..78679ed27 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -5632,6 +5632,258 @@ ], "type": "object" }, + "NotificationDiagnostic": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": [ + "string", + "null" + ] + }, + "delivery_id": { + "type": [ + "string", + "null" + ] + }, + "disposition": { + "type": [ + "string", + "null" + ] + }, + "event_kind": { + "type": "string" + }, + "job_id": { + "type": [ + "string", + "null" + ] + }, + "last_failure_at": { + "type": [ + "string", + "null" + ] + }, + "last_failure_code": { + "type": [ + "string", + "null" + ] + }, + "merge_request_id": { + "type": [ + "string", + "null" + ] + }, + "model_processing_confirmed": { + "description": "No model-processing evidence is currently collected by this read contract.", + "type": "boolean" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "recipient_current": { + "description": "Current assignment identity only; null for fixed-worker or absent recipients.", + "type": [ + "boolean", + "null" + ] + }, + "recipient_role": { + "type": "string" + }, + "retry_candidate": { + "description": "Saved delivery candidate, NOT permission to send. Live recipient and binding checks remain required.", + "type": "boolean" + }, + "source_event_id": { + "type": "string" + }, + "source_id": { + "type": "string" + }, + "stage": { + "$ref": "#/components/schemas/NotificationDiagnosticStage" + }, + "target_runtime_id": { + "type": [ + "string", + "null" + ] + }, + "target_worker_id": { + "type": [ + "string", + "null" + ] + }, + "ticket": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "source_id", + "source_event_id", + "event_kind", + "recipient_role", + "stage", + "retry_candidate", + "model_processing_confirmed" + ], + "type": "object" + }, + "NotificationDiagnosticStage": { + "enum": [ + "historical", + "awaiting_routing", + "suppressed", + "deferred", + "pending", + "reserved", + "request_persisted", + "runtime_rejected", + "acceptance_unknown", + "delivery_failed", + "runtime_accepted" + ], + "type": "string" + }, + "NotificationDiagnosticsPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/NotificationDiagnostic" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "source_checks": { + "items": { + "$ref": "#/components/schemas/NotificationSourceCheck" + }, + "type": "array" + } + }, + "required": [ + "items", + "source_checks" + ], + "type": "object" + }, + "NotificationDiagnosticsQuery": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": [ + "string", + "null" + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "delivery_id": { + "type": [ + "string", + "null" + ] + }, + "job_id": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "merge_request_id": { + "type": [ + "string", + "null" + ] + }, + "source_event_id": { + "type": [ + "string", + "null" + ] + }, + "source_id": { + "description": "Exact source identity for a single-record lookup.", + "type": [ + "string", + "null" + ] + }, + "ticket": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "NotificationSourceCheck": { + "additionalProperties": false, + "properties": { + "captured": { + "description": "Whether the origin has any captured source (independent of pagination/recipient filters).\nA present origin with false here is unconnected, not intentionally suppressed.", + "type": "boolean" + }, + "origin_present": { + "description": "Whether an exact durable origin is present; null means that reference form was not verified.", + "type": [ + "boolean", + "null" + ] + }, + "reference": { + "type": "string" + }, + "resource_kind": { + "type": "string" + } + }, + "required": [ + "resource_kind", + "reference", + "captured" + ], + "type": "object" + }, "Nullable_string": { "type": [ "string", @@ -17235,7 +17487,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904" + "x-yoi-source-digest": "sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04" }, "openapi": "3.1.0", "paths": { @@ -24539,6 +24791,103 @@ ] } }, + "/api/w/{workspace_id}/notification-diagnostics": { + "get": { + "operationId": "notification_diagnostics", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/NotificationDiagnosticsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDiagnosticsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/objectives": { "get": { "operationId": "objective_list", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index d1ff43cbe..babda02a3 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) +// Input contract: 0.1.0 (sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ae074cee0..0d7c530d0 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) +// Input contract: 0.1.0 (sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 1f1c55747..9f917f173 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) +// Input contract: 0.1.0 (sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 506a3c822..462762673 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) +// Input contract: 0.1.0 (sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check @@ -61,6 +61,14 @@ export type MergeStrategy = "fast_forward" | "merge"; export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array; slug?: string | null; targets?: Array; title: string; workflow_state?: TicketState | null; }; +export type NotificationDiagnostic = { assignment_id?: string | null; delivery_id?: string | null; disposition?: string | null; event_kind: string; job_id?: string | null; last_failure_at?: string | null; last_failure_code?: string | null; merge_request_id?: string | null; model_processing_confirmed: boolean; purpose?: string | null; reason?: string | null; recipient_current?: boolean | null; recipient_role: string; retry_candidate: boolean; source_event_id: string; source_id: string; stage: NotificationDiagnosticStage; target_runtime_id?: string | null; target_worker_id?: string | null; ticket?: string | null; }; + +export type NotificationDiagnosticStage = "historical" | "awaiting_routing" | "suppressed" | "deferred" | "pending" | "reserved" | "request_persisted" | "runtime_rejected" | "acceptance_unknown" | "delivery_failed" | "runtime_accepted"; + +export type NotificationDiagnosticsPage = { items: Array; next_cursor?: string | null; source_checks: Array; }; + +export type NotificationSourceCheck = { captured: boolean; origin_present?: boolean | null; reference: string; resource_kind: string; }; + export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; state: string; title: string; updated_at?: string | null; }; export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index e2482f37b..659596f61 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) +// Input contract: 0.1.0 (sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check