fix: project Job receipts and pinned notification recipients

This commit is contained in:
2026-10-15 18:28:11 +09:00
parent c155259c8e
commit b9e7d5675b
11 changed files with 59 additions and 12 deletions
+2
View File
@@ -61,6 +61,8 @@ pub struct NotificationDiagnostic {
pub purpose: Option<String>, pub purpose: Option<String>,
pub delivery_id: Option<String>, pub delivery_id: Option<String>,
pub stage: NotificationDiagnosticStage, 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. /// 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. /// Can remain true alongside a later preflight failure, rejection or historical recipient.
pub acceptance_outcome_unknown: bool, pub acceptance_outcome_unknown: bool,
@@ -79,7 +79,7 @@ async fn notification_tool_and_wip_share_bounded_read_contract() {
let client = Arc::new(Client { let client = Arc::new(Client {
requests: Mutex::new(vec![]), requests: Mutex::new(vec![]),
status: 200, 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 (meta, tool) = definition(client.clone())();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
@@ -142,6 +142,21 @@ async fn job_notification_routing_common_policy_distinguishes_suppression_failur
store.route_job_notification("w", &source, &facts).unwrap(), store.route_job_notification("w", &source, &facts).unwrap(),
disposition == "deliver" 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!( assert_eq!(
job_notice_evidence(&store, &source), 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 store = SqliteWorkspaceStore::open_for_test(path).unwrap();
let pending = store.list_pending_job_notifications("w", 100).unwrap(); let pending = store.list_pending_job_notifications("w", 100).unwrap();
assert_eq!(pending.len(), 4); 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 let checker_body = store
.job_notification_request("w", &checker) .job_notification_request("w", &checker)
.unwrap() .unwrap()
@@ -157,14 +157,16 @@ pub(super) fn page_with_connection(
d.disposition,d.reason,d.purpose,d.delivery_id, d.disposition,d.reason,d.purpose,d.delivery_id,
ar.delivery_id IS NOT NULL, ar.delivery_id IS NOT NULL,
(r.delivery_id IS NOT NULL OR ap.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), (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(ar.reserved_source_json,'$.runtime_id')), 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(ar.reserved_source_json,'$.worker_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, 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, 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')), 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} {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_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 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_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)?), purpose: bounded(row.get(10)?),
delivery_id: bounded(row.get(11)?), delivery_id: bounded(row.get(11)?),
stage, stage,
legacy_evidence: row.get(22)?,
acceptance_outcome_unknown, acceptance_outcome_unknown,
target_resolution, target_resolution,
target_runtime_id, target_runtime_id,
@@ -51,6 +51,10 @@ async fn job_notification_real_worker_ack_loss_reopen_and_parallel_recovery_comm
let evidence = job_notification_evidence(&api, &id); let evidence = job_notification_evidence(&api, &id);
assert!(!evidence.2); assert!(!evidence.2);
assert_eq!(evidence.3.as_deref(), Some("acceptance_unknown")); 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); assert_eq!(delivery_receipts(&api, &target).len(), 1);
// Reopen only Backend persistence, retaining the real Runtime receipt/session. // Reopen only Backend persistence, retaining the real Runtime receipt/session.
let mut reopened = api.clone(); 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; delivery_wait_idle(&reopened, &target, 1).await;
assert!(job_notification_evidence(&reopened, &id).2); 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_receipts(&reopened, &target).len(), 1);
assert_eq!(delivery_commits(&reopened, &target), vec![pinned.0.clone()]); assert_eq!(delivery_commits(&reopened, &target), vec![pinned.0.clone()]);
let attempts = execution.attempts.lock().unwrap(); let attempts = execution.attempts.lock().unwrap();
+6 -1
View File
@@ -5678,6 +5678,10 @@
"null" "null"
] ]
}, },
"legacy_evidence": {
"description": "A retained pre-cutover Job receipt contributes to this projection.",
"type": "boolean"
},
"merge_request_id": { "merge_request_id": {
"type": [ "type": [
"string", "string",
@@ -5752,6 +5756,7 @@
"event_kind", "event_kind",
"recipient_role", "recipient_role",
"stage", "stage",
"legacy_evidence",
"acceptance_outcome_unknown", "acceptance_outcome_unknown",
"target_resolution", "target_resolution",
"retry_candidate", "retry_candidate",
@@ -17507,7 +17512,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3" "x-yoi-source-digest": "sha256:6341ac1bd973233b63b0e9cded8d42c1f6f13ff9d33c1ef1d7b22cd424390b96"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // 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<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; }; export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; 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"; export type NotificationDiagnosticStage = "historical" | "awaiting_routing" | "suppressed" | "deferred" | "pending" | "reserved" | "request_persisted" | "runtime_rejected" | "acceptance_unknown" | "delivery_failed" | "runtime_accepted";
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check