fix: sanitize orchestrator queue attention
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user