fix: sanitize orchestrator queue attention

This commit is contained in:
2026-08-27 12:46:10 +09:00
parent 060f280fdf
commit 5ca0ea9228
7 changed files with 405 additions and 149 deletions
+34 -70
View File
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore;
use session_store::FsWorkerStore;
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 3;
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -910,6 +905,7 @@ struct OrchestratorActiveWorkItem {
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestratorQueuedWorkItem {
id: String,
resource_key: Option<String>,
title: String,
classification: OrchestratorQueuedClassification,
waiting_reason: Option<String>,
@@ -974,22 +970,6 @@ impl OrchestratorQueueAttentionNoticeResult {
}
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateContext {
workspace: String,
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
omitted_ticket_count: usize,
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateTicket {
id: String,
title: String,
classification: &'static str,
waiting_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelRowHitBox {
rect: Rect,
@@ -1325,7 +1305,16 @@ impl DashboardApp {
if self.orchestrator_work_set.is_empty() {
self.refresh_orchestrator_work_set();
}
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?;
let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
Ok(Some(notice)) => notice,
Ok(None) => return None,
Err(error) => {
self.notice = Some(format!(
"Orchestrator queued-work attention not delivered: {error}"
));
return None;
}
};
if self
.orchestrator_queue_attention
.as_ref()
@@ -3645,6 +3634,7 @@ fn derive_orchestrator_work_set(
};
Some(OrchestratorQueuedWorkItem {
id: ticket.id.clone(),
resource_key: ticket.resource_key.clone(),
title: ticket.title.clone(),
classification,
waiting_reason,
@@ -3728,72 +3718,46 @@ fn orchestrator_work_set_fingerprint(
}
fn orchestrator_queue_attention_notice(
panel: &WorkspacePanelViewModel,
work_set: &OrchestratorWorkSet,
) -> Option<OrchestratorQueueAttentionNotice> {
) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
if work_set.has_active_inprogress() {
return None;
return Ok(None);
}
let actionable = work_set.actionable_queued();
if actionable.is_empty() {
return None;
return Ok(None);
}
let waiting = work_set
.queued
.iter()
.filter(|item| item.waiting_reason.is_some())
.collect::<Vec<_>>();
let ticket_count = actionable.len() + waiting.len();
let actionable_tickets = actionable
.iter()
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let remaining_capacity =
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len());
let waiting_tickets = waiting
.iter()
.take(remaining_capacity)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let rendered =
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
workspace: bounded_progress_text(
&panel.header.workspace_label,
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
),
actionable_tickets,
waiting_tickets,
omitted_ticket_count: ticket_count
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
.filter(|item| item.waiting_reason.is_some());
let tickets = actionable
.into_iter()
.chain(waiting)
.map(|item| {
let resource_key = item
.resource_key
.clone()
.ok_or("queued Ticket is missing its required resource key")?;
worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
.map_err(|_| "queued Ticket has an invalid resource key")
})
.ok()?;
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS);
.collect::<Result<Vec<_>, _>>()?;
let context = worker::OrchestratorQueueAttentionContext::new(tickets);
let message = render_orchestrator_queue_attention_template(&context)
.map_err(|_| "queued-work attention prompt rendering failed")?;
let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
Some(OrchestratorQueueAttentionNotice {
Ok(Some(OrchestratorQueueAttentionNotice {
message,
fingerprint,
})
}
fn orchestrator_queue_template_ticket(
item: &&OrchestratorQueuedWorkItem,
) -> OrchestratorQueueTemplateTicket {
OrchestratorQueueTemplateTicket {
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
classification: item.classification.as_str(),
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
}),
}
}))
}
fn render_orchestrator_queue_attention_template(
context: &OrchestratorQueueTemplateContext,
context: &worker::OrchestratorQueueAttentionContext,
) -> Result<String, worker::CatalogError> {
worker::PromptCatalog::builtins_only()?
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
.orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
}
fn orchestrator_work_set_detail(
+87 -8
View File
@@ -2969,7 +2969,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
}
#[test]
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = vec![panel_test_ticket_row(
"00001QUEUE",
@@ -2989,11 +2989,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
request
.notice
.message
.starts_with("Workspace Dashboard observed")
.starts_with("Queued Tickets require attention:")
);
assert!(request.notice.message.contains("00001QUEUE"));
assert!(request.notice.message.contains("new_queued"));
assert!(request.notice.message.contains("queued -> inprogress"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(
request
.notice
.message
.contains("Reread the current Ticket state before acting")
);
assert!(
!request
.notice
.message
.contains(&app.panel.header.workspace_label)
);
for hidden in [
"00001QUEUE",
"Workspace:",
"workspace_id",
"new_queued",
"bounded",
"queued -> inprogress",
] {
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
}
}
#[test]
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
let mut row = panel_test_ticket_row(
"00001QUEUE",
"Queued work",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = None;
app.panel.rows = vec![row];
app.refresh_orchestrator_work_set();
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
assert_eq!(
app.notice.as_deref(),
Some(
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
)
);
}
#[test]
fn queued_attention_truncates_only_when_tickets_are_omitted() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
let mut row = panel_test_ticket_row(
&format!("opaque-{index}"),
&format!("Queued work {index}"),
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
row
})
.collect();
app.refresh_orchestrator_work_set();
let request = app
.prepare_orchestrator_queue_attention_notice()
.expect("bounded queued-work attention");
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
assert!(!request.notice.message.contains("T-21"));
assert!(
request
.notice
.message
.contains("were omitted from this notice: 1")
);
assert!(!request.notice.message.contains("opaque-"));
}
#[test]
@@ -3083,7 +3159,9 @@ fn planned_queued_prompts_when_active_work_clears() {
.prepare_orchestrator_queue_attention_notice()
.expect("planned queued work should prompt after active work clears");
assert!(request.notice.message.contains("planned_queued"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("planned_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
assert!(
!request
.notice
@@ -3138,8 +3216,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
.prepare_orchestrator_queue_attention_notice()
.expect("queued ticket state should be rediscovered safely");
assert!(request.notice.message.contains("new_queued"));
assert!(request.notice.message.contains("00001QUEUE"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("new_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
}
#[test]
+3 -2
View File
@@ -33,8 +33,9 @@ pub use manifest::{
};
pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
prompt_schema_source,
CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
};
pub use prompt::source::PromptCatalogSource;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
+149
View File
@@ -144,8 +144,93 @@ impl WorkerPrompt {
];
}
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
}
impl OrchestratorQueueAttentionTicket {
pub fn new(
resource_key: impl Into<String>,
title: impl Into<String>,
) -> Result<Self, CatalogError> {
let resource_key = resource_key.into();
if !is_ticket_resource_key(&resource_key) {
return Err(CatalogError::InvalidQueueAttentionResourceKey);
}
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
})
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionContext {
tickets: Vec<OrchestratorQueueAttentionTicket>,
separator: &'static str,
omitted_ticket_count: usize,
}
impl OrchestratorQueueAttentionContext {
pub const MAX_TICKETS: usize = 20;
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
Self {
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
separator: "",
omitted_ticket_count,
}
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
Tui,
}
impl OrchestratorQueueAttentionPrompt {
fn key(self) -> &'static str {
match self {
Self::Server => "internal.workspace_orchestrator_queue_attention",
Self::Tui => "panel.orchestrator_idle_queue_notice",
}
}
}
fn is_ticket_resource_key(input: &str) -> bool {
input.len() <= 32
&& input.strip_prefix("T-").is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, character) in input.chars().enumerate() {
if index == max_chars {
output.push('…');
break;
}
output.push(if character.is_control() {
' '
} else {
character
});
}
output
}
#[derive(Debug, Error)]
pub enum CatalogError {
#[error("queued Ticket resource key is missing or invalid")]
InvalidQueueAttentionResourceKey,
#[error("failed to build builtin Prompt source tree: {0}")]
BuiltinTree(String),
#[error("failed to evaluate builtin Prompt source tree: {0}")]
@@ -322,6 +407,14 @@ impl PromptCatalog {
self.render_name(key, Value::from_serialize(context))
}
pub fn orchestrator_queue_attention(
&self,
prompt: OrchestratorQueueAttentionPrompt,
context: &OrchestratorQueueAttentionContext,
) -> Result<String, CatalogError> {
self.render_serializable(prompt.key(), context)
}
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
let template = self
.env
@@ -650,6 +743,62 @@ mod tests {
assert!(reviewer.contains("target-only movement does not invalidate approval"));
}
#[test]
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
let catalog = PromptCatalog::builtins_only().unwrap();
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
OrchestratorQueueAttentionTicket::new(
format!("T-{index}"),
format!("Ticket {index}\nwith control\u{7}"),
)
.unwrap()
})
.collect();
let context = OrchestratorQueueAttentionContext::new(tickets);
let server = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
let tui = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
assert!(server.contains("Re-query current Ticket authority"));
assert!(server.contains("Reread the current Ticket state before acting"));
for secret in [
"workspace_id",
"Workspace:",
"runtime_id",
"worker_id",
"bounded",
] {
assert!(!server.contains(secret), "leaked {secret}: {server}");
}
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();
let context = OrchestratorQueueAttentionContext::new(vec![
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
]);
let rendered = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(rendered.contains("- T-541 — Attention contract"));
assert!(!rendered.contains("omitted"));
assert!(matches!(
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
Err(CatalogError::InvalidQueueAttentionResourceKey)
));
}
#[test]
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
let invalid = BTreeMap::from([
+119 -43
View File
@@ -327,8 +327,6 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option<Pat
}
}
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention";
static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock<
worker_runtime::auth::RuntimeIdentityMaterial,
> = std::sync::LazyLock::new(|| {
@@ -7241,25 +7239,21 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
return;
}
let shown = queued
.iter()
.take(ORCHESTRATOR_ATTENTION_TICKET_LIMIT)
.map(|ticket| {
format!(
"- {} — {}",
bounded_orchestrator_attention_text(&ticket.id, 80),
bounded_orchestrator_attention_text(&ticket.title, 240)
)
})
.collect::<Vec<_>>()
.join("\n");
let omitted = queued
.len()
.saturating_sub(ORCHESTRATOR_ATTENTION_TICKET_LIMIT);
let omitted_line = if omitted == 0 {
String::new()
} else {
format!("Additional queued Tickets omitted from this notice: {omitted}\n")
let attention_context = match orchestrator_queue_attention_context(
&api.config.workspace_id,
&api.config.workspace_id,
&queued,
) {
Ok(context) => context,
Err(error) => {
tracing::warn!(
workspace_id = %api.config.workspace_id,
candidate_count = queued.len(),
diagnostic = error,
"orchestrator backlog attention projection rejected"
);
return;
}
};
let Ok(Some(config_state)) = api
.config_store
@@ -7276,16 +7270,19 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else {
return;
};
let content = match catalog.render_serializable(
ORCHESTRATOR_ATTENTION_PROMPT_NAME,
&BTreeMap::from([
("omitted_line", omitted_line.as_str()),
("workspace_id", api.config.workspace_id.as_str()),
("ticket_lines", shown.as_str()),
]),
let content = match catalog.orchestrator_queue_attention(
worker::OrchestratorQueueAttentionPrompt::Server,
&attention_context,
) {
Ok(content) => content,
Err(_) => return,
Err(error) => {
tracing::warn!(
workspace_id = %api.config.workspace_id,
diagnostic = %error,
"orchestrator backlog attention rendering failed"
);
return;
}
};
let accepted = api
.runtime
@@ -7305,20 +7302,26 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
}
}
fn bounded_orchestrator_attention_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, character) in input.chars().enumerate() {
if index == max_chars {
output.push('…');
break;
}
output.push(if character.is_control() {
' '
} else {
character
});
fn orchestrator_queue_attention_context(
expected_workspace_id: &str,
candidate_workspace_id: &str,
tickets: &[ticket::TicketSummary],
) -> std::result::Result<worker::OrchestratorQueueAttentionContext, &'static str> {
if candidate_workspace_id != expected_workspace_id {
return Err("foreign_workspace_ticket_projection");
}
output
let tickets = tickets
.iter()
.map(|ticket| {
let resource_key = ticket
.resource_key
.clone()
.ok_or("missing_ticket_resource_key")?;
worker::OrchestratorQueueAttentionTicket::new(resource_key, ticket.title.clone())
.map_err(|_| "invalid_ticket_resource_key")
})
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(worker::OrchestratorQueueAttentionContext::new(tickets))
}
fn require_online_workspace_orchestrator_source(
@@ -19585,7 +19588,8 @@ mod tests {
#[tokio::test]
async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Recover queued work");
input.workflow_state = Some(TicketWorkflowState::Queued);
@@ -19604,6 +19608,8 @@ mod tests {
.await
.unwrap();
assert!(started.online);
let startup_inputs = execution.take_inputs();
assert_eq!(startup_inputs.len(), 1);
assert_eq!(
api.orchestrator_attention_fingerprint
.lock()
@@ -19639,6 +19645,76 @@ mod tests {
.as_deref(),
Some(ticket_ref.id.as_str())
);
let notifications = execution.take_inputs();
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].0.worker_id.to_string(), worker_id);
let content = &notifications[0].1;
assert!(content.starts_with("Queued Tickets require attention:"));
assert!(
content.contains(&format!(
"- {} — Recover queued work",
ticket_ref.resource_key.as_deref().unwrap()
)),
"unexpected notification body: {content:?}"
);
assert!(content.contains("Reread the current Ticket state before acting"));
assert!(!content.contains(ticket_ref.id.as_str()));
assert!(!content.contains(TEST_WORKSPACE_ID));
assert!(!content.contains("bounded"));
assert!(!content.contains("omitted"));
let candidates = backend
.list(ticket::TicketListQuery::states([
ticket::TicketListState::Queued,
]))
.unwrap();
let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS
+ 1)
.map(|index| {
let mut candidate = candidates[0].clone();
candidate.id = format!("opaque-{index}");
candidate.resource_key = Some(format!("T-{index}"));
candidate.title = format!("Queued {index}");
candidate
})
.collect::<Vec<_>>();
let truncated = orchestrator_queue_attention_context(
TEST_WORKSPACE_ID,
TEST_WORKSPACE_ID,
&truncated_candidates,
)
.unwrap();
let rendered = worker::PromptCatalog::builtins_only()
.unwrap()
.orchestrator_queue_attention(
worker::OrchestratorQueueAttentionPrompt::Server,
&truncated,
)
.unwrap();
assert!(rendered.contains("- T-20 — Queued 20"));
assert!(!rendered.contains("T-21"));
assert!(rendered.contains("were omitted from this notice: 1"));
assert!(!rendered.contains("opaque-"));
truncated_candidates[0].resource_key = None;
assert_eq!(
orchestrator_queue_attention_context(
TEST_WORKSPACE_ID,
TEST_WORKSPACE_ID,
&truncated_candidates
)
.unwrap_err(),
"missing_ticket_resource_key"
);
assert_eq!(
orchestrator_queue_attention_context(
TEST_WORKSPACE_ID,
"foreign-workspace",
&truncated_candidates
)
.unwrap_err(),
"foreign_workspace_ticket_projection"
);
}
#[tokio::test]