use super::*;
pub(crate) fn notification_class(kind: AutomationKind) -> Class {
match kind {
AutomationKind::BackgroundCommand => Class::NotificationBackgroundCommand,
AutomationKind::Workflow => Class::NotificationWorkflow,
AutomationKind::Agent => Class::NotificationSubagent,
AutomationKind::Monitor => Class::NotificationMonitor,
AutomationKind::Task => Class::NotificationTask,
}
}
pub(crate) const NOTIFICATION_RESULT_TAG: &str = "<result>";
pub const SCHEDULED_TASK_FIRE_SUBTYPE: &str = "scheduled_task_fire";
pub(crate) const PROMPT_SOURCE_SYSTEM: &str = "system";
impl Record {
#[must_use]
pub fn is_scheduled_task_fire(&self) -> bool {
self.is_type("system") && self.subtype.as_deref() == Some(SCHEDULED_TASK_FIRE_SUBTYPE)
}
#[must_use]
pub fn scheduled_fire_instant(&self) -> Option<&str> {
let content = self.content_str()?.trim_end();
let inner = content.strip_suffix(')')?;
let open = inner.rfind('(')?;
let when = inner[open + 1..].trim();
(!when.is_empty()).then_some(when)
}
#[must_use]
pub fn is_scheduled_fire_prompt(&self) -> bool {
if !self.is_type("user") || !self.is_meta.unwrap_or(false) {
return false;
}
if self.prompt_source.as_deref() != Some(PROMPT_SOURCE_SYSTEM) {
return false;
}
if self.origin.is_some() {
return false;
}
!self
.raw_message_text()
.is_some_and(|raw| is_peer_message(&raw))
}
}
#[derive(Debug, Clone, Default)]
pub struct ScheduleFireIndex {
by_parent: HashMap<String, String>,
}
impl ScheduleFireIndex {
#[must_use]
pub fn from_records<'a>(records: impl Iterator<Item = &'a Record>) -> Self {
let mut by_parent = HashMap::new();
for rec in records {
if !rec.is_scheduled_task_fire() {
continue;
}
if let (Some(uuid), Some(when)) = (rec.uuid.as_deref(), rec.scheduled_fire_instant()) {
by_parent.insert(uuid.to_string(), when.to_string());
}
}
Self { by_parent }
}
#[must_use]
pub fn instant_for(&self, parent_uuid: Option<&str>) -> Option<&str> {
self.by_parent.get(parent_uuid?).map(String::as_str)
}
}
pub(crate) fn automation_label_for_section(section: &str) -> String {
let TaskIds { ids, orphan_kind } = section_task_ids(section);
let status = extract_xml_tag(section, "status");
let summary = extract_xml_tag(section, "summary");
let event = extract_xml_tag(section, "event");
let kind = AutomationKind::from_summary(summary.as_deref());
let id = if ids.is_empty() {
"?".to_string()
} else {
ids.join(", ")
};
let event_norm = event
.as_deref()
.filter(|e| !e.is_empty())
.map(normalize_line);
let status = status
.as_deref()
.map(str::to_string)
.or(event_norm)
.unwrap_or_else(|| "completed".to_string());
let mut head = format!("[{} {id} {status}]", kind.slug());
if let Some(k) = orphan_kind {
head.push_str(&format!(" (orphan reconciliation: {k})"));
}
match summary.as_deref() {
Some(sum) if !sum.is_empty() => format!("{head} {}", normalize_line(sum)),
_ => head,
}
}
pub(crate) fn classify_batched_sections(raw: &str, out: &mut Vec<Class>) -> bool {
let mut matched = false;
let mut notif_spans: Vec<(usize, usize)> = Vec::new();
scan_tag_sections(
raw,
TASK_NOTIFICATION_PREFIX,
TASK_NOTIFICATION_CLOSE,
|offset, section| {
let kind = AutomationKind::from_summary(extract_xml_tag(section, "summary").as_deref());
push_unique(out, notification_class(kind));
if section.contains(NOTIFICATION_RESULT_TAG) {
push_unique(out, Class::CommInbox);
}
notif_spans.push((offset, offset + section.len()));
matched = true;
},
);
for peer in parse_all_peer_sections(raw) {
if notif_spans
.iter()
.any(|&(s, e)| peer.offset >= s && peer.offset < e)
{
continue;
}
push_unique(
out,
if peer.is_signal {
Class::CommSignal
} else {
Class::CommInbox
},
);
matched = true;
}
matched
}
#[derive(Debug, Clone)]
pub struct RecordTextSection {
pub class: Class,
pub text: String,
pub direction: Option<(String, String)>,
pub task_ids: TaskIds,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundComm {
pub class: Class,
pub from: String,
pub body: String,
}
pub(crate) fn push_unique(out: &mut Vec<Class>, c: Class) {
if !out.contains(&c) {
out.push(c);
}
}
#[allow(dead_code)]
pub trait SpawnLookup {
fn child_for_spawn_tool_use_id(&self, tool_use_id: &str) -> Option<String>;
fn child_for_spawn_name(&self, name: &str) -> Option<String>;
}
#[allow(dead_code)]
pub struct ClassifyCtx<'a> {
pub owner_id: Option<&'a str>,
pub owner_name: Option<&'a str>,
pub is_subagent: bool,
pub parent_id: Option<&'a str>,
pub is_transcript_opener: bool,
pub spawn: Option<&'a dyn SpawnLookup>,
pub resume_prompt_uuids: Option<&'a HashSet<String>>,
pub summarize: Option<&'a SummarizeIndex>,
pub schedule_fires: Option<&'a ScheduleFireIndex>,
}
#[allow(dead_code)]
impl<'a> ClassifyCtx<'a> {
#[must_use]
pub fn top_level() -> Self {
ClassifyCtx {
owner_id: None,
owner_name: None,
is_subagent: false,
parent_id: None,
is_transcript_opener: false,
spawn: None,
resume_prompt_uuids: None,
summarize: None,
schedule_fires: None,
}
}
}
impl std::fmt::Debug for ClassifyCtx<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClassifyCtx")
.field("owner_id", &self.owner_id)
.field("owner_name", &self.owner_name)
.field("is_subagent", &self.is_subagent)
.field("parent_id", &self.parent_id)
.field("is_transcript_opener", &self.is_transcript_opener)
.field("has_spawn_lookup", &self.spawn.is_some())
.field(
"resume_prompts",
&self.resume_prompt_uuids.map_or(0, HashSet::len),
)
.finish()
}
}
#[allow(dead_code)]
impl Record {
#[must_use]
pub fn teammate_message(&self) -> Option<TeammateMessage> {
if !self.is_type("user") {
return None;
}
let text = self.raw_message_text()?;
parse_teammate_message(&text)
}
#[must_use]
pub fn is_teammate_message_record(&self) -> bool {
self.teammate_message().is_some()
}
}