diff --git a/crates/server-api/src/notification.rs b/crates/server-api/src/notification.rs index 6c96a93df..b0503c55e 100644 --- a/crates/server-api/src/notification.rs +++ b/crates/server-api/src/notification.rs @@ -61,6 +61,8 @@ pub struct NotificationDiagnostic { pub purpose: Option, pub delivery_id: Option, pub stage: NotificationDiagnosticStage, + /// A retained pre-cutover Job receipt contributes to this projection. + pub legacy_evidence: bool, /// Conservative: a persisted request without ACK is enough; this does not prove a send occurred. /// Can remain true alongside a later preflight failure, rejection or historical recipient. pub acceptance_outcome_unknown: bool, diff --git a/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs b/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs index f45a93cbe..ba4dfb5c9 100644 --- a/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs @@ -79,7 +79,7 @@ async fn notification_tool_and_wip_share_bounded_read_contract() { let client = Arc::new(Client { requests: Mutex::new(vec![]), status: 200, - body: json!({"items":[{"source_id":"notification-source:5","source_event_id":"mr-event:m:e","event_kind":"mr_review","recipient_role":"orchestrator","stage":"acceptance_unknown","acceptance_outcome_unknown":true,"target_resolution":"multiple_authorized_targets","retry_candidate":true,"model_processing_confirmed":false}],"next_cursor":null,"source_checks":[]}).to_string(), + body: json!({"items":[{"source_id":"notification-source:5","source_event_id":"mr-event:m:e","event_kind":"mr_review","recipient_role":"orchestrator","stage":"acceptance_unknown","legacy_evidence":false,"acceptance_outcome_unknown":true,"target_resolution":"multiple_authorized_targets","retry_candidate":true,"model_processing_confirmed":false}],"next_cursor":null,"source_checks":[]}).to_string(), }); let (meta, tool) = definition(client.clone())(); let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); diff --git a/crates/workspace-server/src/job_notification_tests.rs b/crates/workspace-server/src/job_notification_tests.rs index 27cbc0779..b32ff9ec4 100644 --- a/crates/workspace-server/src/job_notification_tests.rs +++ b/crates/workspace-server/src/job_notification_tests.rs @@ -142,6 +142,21 @@ async fn job_notification_routing_common_policy_distinguishes_suppression_failur store.route_job_notification("w", &source, &facts).unwrap(), disposition == "deliver" ); + let diagnostics=store.notification_diagnostics("w",&server_api::NotificationDiagnosticsQuery{job_id:Some(name.into()),..Default::default()}).unwrap(); + let item=&diagnostics.items[0]; + assert_eq!(item.disposition.as_deref(),Some(disposition)); + assert_eq!(item.reason.as_deref(),Some(reason)); + assert_eq!(item.job_id.as_deref(),Some(name)); + assert!(diagnostics.source_checks[0].captured); + assert_eq!(diagnostics.source_checks[0].origin_present,Some(true)); + assert!(!item.model_processing_confirmed && !item.legacy_evidence); + if disposition=="deliver" { + assert_eq!(item.stage,server_api::NotificationDiagnosticStage::RequestPersisted); + assert_eq!(item.target_worker_id.as_deref(),Some("replacement")); + } else { + assert!(!item.retry_candidate); + assert_eq!(item.stage,if disposition=="suppress"{server_api::NotificationDiagnosticStage::Suppressed}else{server_api::NotificationDiagnosticStage::Deferred}); + } assert_eq!( job_notice_evidence(&store, &source), ( @@ -534,6 +549,19 @@ fn job_notification_migration_retains_legacy_acks_suppressions_unknown_bytes_and let store = SqliteWorkspaceStore::open_for_test(path).unwrap(); let pending = store.list_pending_job_notifications("w", 100).unwrap(); assert_eq!(pending.len(), 4); + for (id,stage) in [(&accepted,server_api::NotificationDiagnosticStage::RuntimeAccepted),(&clean,server_api::NotificationDiagnosticStage::Suppressed),(&stale,server_api::NotificationDiagnosticStage::Suppressed),(&unknown,server_api::NotificationDiagnosticStage::AcceptanceUnknown),(&ambiguous,server_api::NotificationDiagnosticStage::Deferred)] { + let page=store.notification_diagnostics("w",&server_api::NotificationDiagnosticsQuery{delivery_id:Some(id.clone()),..Default::default()}).unwrap(); + let item=&page.items[0]; + assert_eq!(item.stage,stage); + assert!(item.legacy_evidence); + assert!(!item.model_processing_confirmed); + if id==&accepted { + assert!(!item.retry_candidate && !item.acceptance_outcome_unknown); + assert!(item.target_worker_id.is_some()); + } + let json=serde_json::to_string(item).unwrap(); + assert!(!json.contains("record_json") && !json.contains("notification_suppressed")); + } let checker_body = store .job_notification_request("w", &checker) .unwrap() diff --git a/crates/workspace-server/src/notification_diagnostics.rs b/crates/workspace-server/src/notification_diagnostics.rs index 20b68d04b..acbe313aa 100644 --- a/crates/workspace-server/src/notification_diagnostics.rs +++ b/crates/workspace-server/src/notification_diagnostics.rs @@ -157,14 +157,16 @@ pub(super) fn page_with_connection( 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')), + (a.delivery_id IS NOT NULL OR aa.delivery_id IS NOT NULL OR COALESCE(l.runtime_accepted,0)=1), + 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(r.target_json,'$.runtime_id'),json_extract(ar.reserved_source_json,'$.runtime_id'),json_extract(l.record_json,'$.target.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(r.target_json,'$.worker_id'),json_extract(ar.reserved_source_json,'$.worker_id'),json_extract(l.record_json,'$.target.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, EXISTS(SELECT 1 FROM notification_failures uf WHERE uf.workspace_id=s.workspace_id AND uf.delivery_id=d.delivery_id AND uf.code IN ('acceptance_unknown','acknowledgement_failed')), - (SELECT COUNT(*) FROM (SELECT DISTINCT at.runtime_id,at.worker_id FROM notification_assignment_targets at WHERE at.workspace_id=s.workspace_id AND at.delivery_id=d.delivery_id)) + (SELECT COUNT(*) FROM (SELECT DISTINCT at.runtime_id,at.worker_id FROM notification_assignment_targets at WHERE at.workspace_id=s.workspace_id AND at.delivery_id=d.delivery_id)), + l.delivery_id IS NOT NULL {joins} + LEFT JOIN notification_job_legacy_receipts l ON l.workspace_id=s.workspace_id AND l.delivery_id=d.delivery_id 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 @@ -249,6 +251,7 @@ pub(super) fn page_with_connection( purpose: bounded(row.get(10)?), delivery_id: bounded(row.get(11)?), stage, + legacy_evidence: row.get(22)?, acceptance_outcome_unknown, target_resolution, target_runtime_id, diff --git a/crates/workspace-server/src/server_job_notification_tests.rs b/crates/workspace-server/src/server_job_notification_tests.rs index 3a4c8fb19..0bc7eb637 100644 --- a/crates/workspace-server/src/server_job_notification_tests.rs +++ b/crates/workspace-server/src/server_job_notification_tests.rs @@ -51,6 +51,10 @@ async fn job_notification_real_worker_ack_loss_reopen_and_parallel_recovery_comm let evidence = job_notification_evidence(&api, &id); assert!(!evidence.2); assert_eq!(evidence.3.as_deref(), Some("acceptance_unknown")); + let diagnostics=delivery_http(&api,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?job_id={}",request.job_id),None,StatusCode::OK).await; + assert_eq!(diagnostics["items"][0]["stage"],"acceptance_unknown"); + assert_eq!(diagnostics["items"][0]["target_worker_id"],target.worker_id); + assert_eq!(diagnostics["items"][0]["acceptance_outcome_unknown"],true); assert_eq!(delivery_receipts(&api, &target).len(), 1); // Reopen only Backend persistence, retaining the real Runtime receipt/session. let mut reopened = api.clone(); @@ -77,6 +81,11 @@ async fn job_notification_real_worker_ack_loss_reopen_and_parallel_recovery_comm }); delivery_wait_idle(&reopened, &target, 1).await; assert!(job_notification_evidence(&reopened, &id).2); + let diagnostics=delivery_http(&reopened,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?job_id={}",request.job_id),None,StatusCode::OK).await; + assert_eq!(diagnostics["items"][0]["stage"],"runtime_accepted"); + assert_eq!(diagnostics["items"][0]["target_worker_id"],target.worker_id); + assert_eq!(diagnostics["items"][0]["acceptance_outcome_unknown"],false); + assert_eq!(diagnostics["items"][0]["model_processing_confirmed"],false); assert_eq!(delivery_receipts(&reopened, &target).len(), 1); assert_eq!(delivery_commits(&reopened, &target), vec![pinned.0.clone()]); let attempts = execution.attempts.lock().unwrap(); diff --git a/openapi/server-api.json b/openapi/server-api.json index 62eab5474..9ee166428 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -5678,6 +5678,10 @@ "null" ] }, + "legacy_evidence": { + "description": "A retained pre-cutover Job receipt contributes to this projection.", + "type": "boolean" + }, "merge_request_id": { "type": [ "string", @@ -5752,6 +5756,7 @@ "event_kind", "recipient_role", "stage", + "legacy_evidence", "acceptance_outcome_unknown", "target_resolution", "retry_candidate", @@ -17507,7 +17512,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3" + "x-yoi-source-digest": "sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 373ede88f..7a4e35148 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:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) +// Input contract: 0.1.0 (sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96) // 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 7fd1cdfc4..6164712ee 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:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) +// Input contract: 0.1.0 (sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96) // 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 d03dad5e2..8d9d93399 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:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) +// Input contract: 0.1.0 (sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96) // 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 7689b1a30..35f2c70df 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:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) +// Input contract: 0.1.0 (sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96) // 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,7 +61,7 @@ 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 = { acceptance_outcome_unknown: boolean; 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_resolution: NotificationTargetResolution; target_runtime_id?: string | null; target_worker_id?: string | null; ticket?: string | null; }; +export type NotificationDiagnostic = { acceptance_outcome_unknown: boolean; 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; legacy_evidence: boolean; 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_resolution: NotificationTargetResolution; 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"; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index b3d6d8b6b..8a7436fbc 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:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) +// Input contract: 0.1.0 (sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96) // 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