From e33729647c889ec787b44be9bc617a4c2821d0a5 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 15 Oct 2026 18:15:47 +0900 Subject: [PATCH] fix: preserve notification uncertainty and target provenance --- crates/server-api/src/notification.rs | 15 +++++ .../ticket/notification_diagnostics/tests.rs | 59 ++++++++++++++++++- .../src/notification_diagnostics.rs | 41 +++++++++++-- .../src/notification_diagnostics_tests.rs | 50 ++++++++++++++++ openapi/server-api.json | 22 ++++++- .../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 | 6 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 10 files changed, 188 insertions(+), 13 deletions(-) diff --git a/crates/server-api/src/notification.rs b/crates/server-api/src/notification.rs index fb26518a8..6c96a93df 100644 --- a/crates/server-api/src/notification.rs +++ b/crates/server-api/src/notification.rs @@ -33,6 +33,16 @@ pub enum NotificationDiagnosticStage { RuntimeAccepted, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NotificationTargetResolution { + Unresolved, + Reserved, + RequestPersisted, + RuntimeAccepted, + MultipleAuthorizedTargets, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct NotificationDiagnostic { @@ -51,6 +61,11 @@ pub struct NotificationDiagnostic { pub purpose: Option, pub delivery_id: Option, pub stage: NotificationDiagnosticStage, + /// 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, + /// Failure records do not identify a particular authorized replacement target. + pub target_resolution: NotificationTargetResolution, pub target_runtime_id: Option, pub target_worker_id: Option, pub last_failure_code: Option, 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 f7d225498..f45a93cbe 100644 --- a/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/notification_diagnostics/tests.rs @@ -1,4 +1,51 @@ use super::*; + +#[tokio::test] +async fn notification_wip_executes_same_read_and_honors_profile_denial() { + for denied in [false, true] { + let client = Arc::new(Client { + requests: Mutex::new(vec![]), + status: 200, + body: json!({"items":[],"next_cursor":null,"source_checks":[]}).to_string(), + }); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap(); + let handler = TicketCollectionWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|t| t.projection.surface == NativeTicketSurface::Collection) + .collect(), + ), + permissions: denied.then_some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![], + }), + collection_route: "/tickets".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + let args = BTreeMap::from([("ticket".into(), WipValue::String("T-755".into()))]); + let result = handler + .call( + "notification_diagnostics", + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into(), + }, + ) + .await; + assert_eq!(result.is_err(), denied); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), usize::from(!denied)); + if !denied { + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert_eq!( + requests[0].path, + "/api/w/workspace/notification-diagnostics?ticket=T-755" + ); + } + } +} use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; #[derive(Debug)] struct Client { @@ -32,15 +79,23 @@ 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(), + 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(), }); 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()) + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) .await .unwrap(); + let page: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(page["items"][0]["acceptance_outcome_unknown"], true); + assert_eq!( + page["items"][0]["target_resolution"], + "multiple_authorized_targets" + ); + assert!(page["items"][0]["target_worker_id"].is_null()); let requests = client.requests.lock().unwrap(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); diff --git a/crates/workspace-server/src/notification_diagnostics.rs b/crates/workspace-server/src/notification_diagnostics.rs index 51d168411..20b68d04b 100644 --- a/crates/workspace-server/src/notification_diagnostics.rs +++ b/crates/workspace-server/src/notification_diagnostics.rs @@ -42,6 +42,10 @@ fn source_checks( .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 if let Some((mr, event)) = + id.strip_prefix("mr-event:").and_then(|v| v.split_once(':')) + { + Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3)",params![workspace,mr,event],|r|r.get(0))?) } else { None }; @@ -157,7 +161,9 @@ pub(super) fn page_with_connection( 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 + 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)) {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 @@ -176,6 +182,31 @@ pub(super) fn page_with_connection( let accepted: bool = row.get(14)?; let failure: Option = row.get(17)?; let recipient_current: Option = row.get(19)?; + let unresolved_send: bool = row.get(20)?; + let multiple_targets = row.get::<_, i64>(21)? > 1; + let acceptance_outcome_unknown = !accepted && (prepared || unresolved_send); + use server_api::NotificationTargetResolution as Target; + let target_resolution = if accepted { + Target::RuntimeAccepted + } else if multiple_targets { + Target::MultipleAuthorizedTargets + } else if prepared { + Target::RequestPersisted + } else if reserved { + Target::Reserved + } else { + Target::Unresolved + }; + let target_runtime_id = if multiple_targets && !accepted { + None + } else { + bounded(row.get(15)?) + }; + let target_worker_id = if multiple_targets && !accepted { + None + } else { + bounded(row.get(16)?) + }; let stage = if accepted { Stage::RuntimeAccepted } else if recipient_current == Some(false) { @@ -186,7 +217,7 @@ pub(super) fn page_with_connection( Stage::Suppressed } else if disposition.as_deref() == Some("defer") { Stage::Deferred - } else if failure.as_deref() == Some("acceptance_unknown") { + } else if unresolved_send { Stage::AcceptanceUnknown } else if failure.as_deref() == Some("runtime_rejected") { Stage::RuntimeRejected @@ -218,8 +249,10 @@ pub(super) fn page_with_connection( 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)?), + acceptance_outcome_unknown, + target_resolution, + target_runtime_id, + target_worker_id, last_failure_code: bounded(failure), last_failure_at: bounded(row.get(18)?), retry_candidate, diff --git a/crates/workspace-server/src/notification_diagnostics_tests.rs b/crates/workspace-server/src/notification_diagnostics_tests.rs index 611b18c5d..2ee60821f 100644 --- a/crates/workspace-server/src/notification_diagnostics_tests.rs +++ b/crates/workspace-server/src/notification_diagnostics_tests.rs @@ -1,5 +1,55 @@ // Production Store and SQLite. No Runtime/model side effects are required for diagnostics. #[tokio::test] +async fn notification_diagnostics_preserves_unknown_acceptance_and_marks_replacement_ambiguity() { + use server_api::{NotificationDiagnosticsQuery as Q,NotificationDiagnosticStage as S,NotificationTargetResolution as T}; + for initial_failure in [NotificationFailureCode::AcceptanceUnknown,NotificationFailureCode::AcknowledgementFailed] { + let (_dir,store)=tracking_store_fixture().await; + tracking_store_assign(&store,"a","assign-a"); + let source=tracking_store_source(); + let delivery=delivery_store_reserve_initial(&store,&source); + store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"frozen body",&source).unwrap(); + let key=NotificationDeliveryKey::AssignmentInput{assignment_id:"a".into(),input_event_id:delivery.input_event_id.clone()}; + store.record_notification_delivery_failure("w",&key,initial_failure).unwrap(); + store.record_notification_delivery_failure("w",&key,NotificationFailureCode::TargetStopped).unwrap(); + let query=Q{delivery_id:Some(delivery.delivery_id.clone()),..Default::default()}; + let read=||store.notification_diagnostics("w",&query).unwrap().items.remove(0); + assert_eq!(read().stage,S::AcceptanceUnknown); + assert!(read().acceptance_outcome_unknown); + assert_eq!(read().last_failure_code.as_deref(),Some("target_stopped")); + store.with_conn(|conn|{conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",[])?;Ok(())}).unwrap(); + let replacement=TicketAssignmentTrackingSourceContext{worker_id:"replacement".into(),..source}; + store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"ignored new body",&replacement).unwrap(); + let ambiguous=read(); + assert_eq!(ambiguous.target_resolution,T::MultipleAuthorizedTargets); + assert!(ambiguous.target_runtime_id.is_none() && ambiguous.target_worker_id.is_none()); + store.ack_ticket_assignment_delivery("w",&delivery.delivery_id,&replacement).unwrap(); + let accepted=read(); + assert_eq!(accepted.target_resolution,T::RuntimeAccepted); + assert_eq!(accepted.target_worker_id.as_deref(),Some("replacement")); + assert!(!accepted.acceptance_outcome_unknown); + } +} + +#[tokio::test] +async fn notification_diagnostics_detects_exact_missing_mr_event_after_earlier_capture() { + 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 merge_requests(workspace_id,merge_request_id,repository_id,state,selector_to,created_at,updated_at) SELECT workspace_id,'mr-diagnostic',repository_id,'open','develop','1','1' FROM repositories WHERE workspace_id='w' LIMIT 1",[])?; + for (event,seq) in [("first",1),("missing",2)] { + conn.execute("INSERT INTO merge_request_thread_events(workspace_id,merge_request_id,event_id,sequence,kind,payload_json,created_at) VALUES('w','mr-diagnostic',?1,?2,'review','{}','1')",params![event,seq])?; + } + conn.execute("INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) SELECT workspace_id,ticket_id,'mr-event:mr-diagnostic:first',role,assignment_id,'mr_review',json_set(facts_json,'$.merge_request_id','mr-diagnostic') FROM notification_sources WHERE assignment_id='a' LIMIT 1",[])?; + Ok(()) + }).unwrap(); + let page=store.notification_diagnostics("w",&Q{merge_request_id:Some("mr-diagnostic".into()),source_event_id:Some("mr-event:mr-diagnostic:missing".into()),..Default::default()}).unwrap(); + assert!(page.items.is_empty()); + assert!(page.source_checks[0].captured); + assert_eq!(page.source_checks[1].origin_present,Some(true)); + assert!(!page.source_checks[1].captured); +} +#[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; diff --git a/openapi/server-api.json b/openapi/server-api.json index 78679ed27..62eab5474 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -5635,6 +5635,10 @@ "NotificationDiagnostic": { "additionalProperties": false, "properties": { + "acceptance_outcome_unknown": { + "description": "Conservative: a persisted request without ACK is enough; this does not prove a send occurred.\nCan remain true alongside a later preflight failure, rejection or historical recipient.", + "type": "boolean" + }, "assignment_id": { "type": [ "string", @@ -5719,6 +5723,10 @@ "stage": { "$ref": "#/components/schemas/NotificationDiagnosticStage" }, + "target_resolution": { + "$ref": "#/components/schemas/NotificationTargetResolution", + "description": "Failure records do not identify a particular authorized replacement target." + }, "target_runtime_id": { "type": [ "string", @@ -5744,6 +5752,8 @@ "event_kind", "recipient_role", "stage", + "acceptance_outcome_unknown", + "target_resolution", "retry_candidate", "model_processing_confirmed" ], @@ -5884,6 +5894,16 @@ ], "type": "object" }, + "NotificationTargetResolution": { + "enum": [ + "unresolved", + "reserved", + "request_persisted", + "runtime_accepted", + "multiple_authorized_targets" + ], + "type": "string" + }, "Nullable_string": { "type": [ "string", @@ -17487,7 +17507,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04" + "x-yoi-source-digest": "sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3" }, "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 babda02a3..373ede88f 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:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) +// Input contract: 0.1.0 (sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) // 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 0d7c530d0..7fd1cdfc4 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:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) +// Input contract: 0.1.0 (sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) // 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 9f917f173..d03dad5e2 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:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) +// Input contract: 0.1.0 (sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) // 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 462762673..7689b1a30 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:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) +// Input contract: 0.1.0 (sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) // 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 = { 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 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 NotificationDiagnosticStage = "historical" | "awaiting_routing" | "suppressed" | "deferred" | "pending" | "reserved" | "request_persisted" | "runtime_rejected" | "acceptance_unknown" | "delivery_failed" | "runtime_accepted"; @@ -69,6 +69,8 @@ export type NotificationDiagnosticsPage = { items: Array export type NotificationSourceCheck = { captured: boolean; origin_present?: boolean | null; reference: string; resource_kind: string; }; +export type NotificationTargetResolution = "unresolved" | "reserved" | "request_persisted" | "runtime_accepted" | "multiple_authorized_targets"; + 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 659596f61..b3d6d8b6b 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:ba0b73fac609dc65712a95c3b96f1044b16f82a5775f591813c02e8f392b5a04) +// Input contract: 0.1.0 (sha256:d3f51c6f6b7091ca393c9d197309e280a18357889e9e6df69bbdfcaeb13d0cc3) // 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