1use agent_doc_element::{
4 ElementAuthority, ElementCompositionRole, ElementDescriptor, ElementRealtimeModel,
5 ElementSchedulingRole, ElementShape, ElementSource, ElementWritePolicy,
6};
7use lazily::{ThreadSafeContext, ThreadSafeStateMachine};
8use serde::{Deserialize, Serialize};
9
10pub const DESCRIPTOR: ElementDescriptor = ElementDescriptor {
11 name: "queue",
12 aliases: &[],
13 source: ElementSource::BuiltIn,
14 shape: ElementShape::Component,
15 authority: ElementAuthority::DerivedProjection,
16 write_policy: ElementWritePolicy::ProjectionOnly,
17 scheduling_role: ElementSchedulingRole::ActiveQueue,
18 realtime_model: ElementRealtimeModel::Queue,
19 composition_role: ElementCompositionRole::Consumer,
20 realtime: true,
21};
22
23pub fn descriptor() -> ElementDescriptor {
24 DESCRIPTOR
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum QueueItemLifecycle {
33 Live,
35 Struck,
38}
39
40impl QueueItemLifecycle {
41 pub fn rank(self) -> u8 {
43 match self {
44 QueueItemLifecycle::Live => 0,
45 QueueItemLifecycle::Struck => 1,
46 }
47 }
48
49 pub fn join(self, other: QueueItemLifecycle) -> QueueItemLifecycle {
52 if other.rank() > self.rank() {
53 other
54 } else {
55 self
56 }
57 }
58
59 pub fn classify(item: &str) -> QueueItemLifecycle {
62 let first = item.lines().next().unwrap_or("");
63 if first.contains("~~") {
64 QueueItemLifecycle::Struck
65 } else {
66 QueueItemLifecycle::Live
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case", tag = "kind", content = "key")]
79pub enum QueueItemIdentity {
80 Id(String),
81 FreeText(String),
82}
83
84impl QueueItemIdentity {
85 pub fn from_prompt(prompt: &str) -> Self {
88 match extract_head_id(prompt) {
89 Some(id) => QueueItemIdentity::Id(id),
90 None => QueueItemIdentity::FreeText(normalize_multiline_dedup_text(prompt)),
91 }
92 }
93
94 pub fn is_id_backed(&self) -> bool {
96 matches!(self, QueueItemIdentity::Id(_))
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum QueueItemState {
104 Authored,
105 Mirrored,
106 InProgress,
107 Answered,
108 Struck,
109 Reaped,
110}
111
112impl QueueItemState {
113 pub fn rank(self) -> u8 {
114 match self {
115 QueueItemState::Authored => 0,
116 QueueItemState::Mirrored => 1,
117 QueueItemState::InProgress => 2,
118 QueueItemState::Answered => 3,
119 QueueItemState::Struck => 4,
120 QueueItemState::Reaped => 5,
121 }
122 }
123
124 pub fn is_terminal(self) -> bool {
125 matches!(self, QueueItemState::Reaped)
126 }
127
128 pub fn lifecycle(self) -> QueueItemLifecycle {
131 match self {
132 QueueItemState::Authored
133 | QueueItemState::Mirrored
134 | QueueItemState::InProgress
135 | QueueItemState::Answered => QueueItemLifecycle::Live,
136 QueueItemState::Struck | QueueItemState::Reaped => QueueItemLifecycle::Struck,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum QueueItemEvent {
144 OperatorAuthored,
145 BacklogMirrored,
146 DrainStarted,
147 ExchangeAnswered,
148 StruckThrough,
149 Reaped,
150}
151
152impl QueueItemEvent {
153 pub fn target(self) -> QueueItemState {
155 match self {
156 QueueItemEvent::OperatorAuthored => QueueItemState::Authored,
157 QueueItemEvent::BacklogMirrored => QueueItemState::Mirrored,
158 QueueItemEvent::DrainStarted => QueueItemState::InProgress,
159 QueueItemEvent::ExchangeAnswered => QueueItemState::Answered,
160 QueueItemEvent::StruckThrough => QueueItemState::Struck,
161 QueueItemEvent::Reaped => QueueItemState::Reaped,
162 }
163 }
164}
165
166pub fn transition_queue_item(
169 current: &QueueItemState,
170 event: &QueueItemEvent,
171) -> Option<QueueItemState> {
172 let target = event.target();
173 Some(if target.rank() > current.rank() {
174 target
175 } else {
176 *current
177 })
178}
179
180pub struct QueueItemMachine {
182 ctx: ThreadSafeContext,
183 machine: ThreadSafeStateMachine<QueueItemState, QueueItemEvent>,
184}
185
186impl QueueItemMachine {
187 pub fn new(initial: QueueItemState) -> Self {
188 let ctx = ThreadSafeContext::new();
189 let machine = ThreadSafeStateMachine::new(&ctx, initial, transition_queue_item);
190 Self { ctx, machine }
191 }
192
193 pub fn send(&self, event: QueueItemEvent) -> bool {
194 self.machine.send(&self.ctx, event)
195 }
196
197 pub fn state(&self) -> QueueItemState {
198 self.machine.state(&self.ctx)
199 }
200
201 pub fn transition(initial: QueueItemState, event: QueueItemEvent) -> Option<QueueItemState> {
202 let machine = Self::new(initial);
203 if machine.send(event) {
204 Some(machine.state())
205 } else {
206 None
207 }
208 }
209}
210
211pub fn normalize_multiline_dedup_text(text: &str) -> String {
212 text.split_whitespace()
213 .collect::<Vec<_>>()
214 .join(" ")
215 .to_ascii_lowercase()
216}
217
218fn extract_head_id(prompt: &str) -> Option<String> {
219 let stripped = strip_priority_markers(prompt);
220 let trimmed = stripped.trim();
221 let lower = trimmed.to_ascii_lowercase();
222 let topic = lower.strip_prefix("do ").unwrap_or(&lower).trim();
223 let raw = topic
224 .strip_prefix("[#")
225 .and_then(|rest| rest.split_once(']').map(|(id, _)| id))
226 .or_else(|| topic.strip_prefix('#').map(id_prefix))?;
227 if raw.is_empty()
228 || !raw
229 .chars()
230 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
231 {
232 None
233 } else {
234 Some(raw.to_ascii_lowercase())
235 }
236}
237
238fn id_prefix(text: &str) -> &str {
239 text.split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')))
240 .next()
241 .unwrap_or("")
242}
243
244fn strip_priority_markers(text: &str) -> String {
245 let mut t = text.trim();
246 loop {
247 let trimmed = t.trim_start();
248 let stripped = [
249 "**prioritized**",
250 "__prioritized__",
251 "**pin**",
252 "__pin__",
253 ":pushpin:",
254 ":pin:",
255 "📌",
256 "*prioritized*",
257 "_prioritized_",
258 "*pin*",
259 "_pin_",
260 ":round_pushpin:",
261 "📍",
262 "🚧",
263 ]
264 .iter()
265 .find_map(|marker| trimmed.strip_prefix(marker));
266 match stripped {
267 Some(rest) => t = rest.trim_start(),
268 None => break,
269 }
270 }
271 t.trim().to_string()
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 const ALL_STATES: [QueueItemState; 6] = [
279 QueueItemState::Authored,
280 QueueItemState::Mirrored,
281 QueueItemState::InProgress,
282 QueueItemState::Answered,
283 QueueItemState::Struck,
284 QueueItemState::Reaped,
285 ];
286
287 const ALL_EVENTS: [QueueItemEvent; 6] = [
288 QueueItemEvent::OperatorAuthored,
289 QueueItemEvent::BacklogMirrored,
290 QueueItemEvent::DrainStarted,
291 QueueItemEvent::ExchangeAnswered,
292 QueueItemEvent::StruckThrough,
293 QueueItemEvent::Reaped,
294 ];
295
296 fn t(state: QueueItemState, event: QueueItemEvent) -> QueueItemState {
297 transition_queue_item(&state, &event).expect("transition is total")
298 }
299
300 #[test]
301 fn rank_orders_live_before_struck() {
302 assert!(QueueItemLifecycle::Live.rank() < QueueItemLifecycle::Struck.rank());
303 assert!(QueueItemLifecycle::Live < QueueItemLifecycle::Struck);
304 }
305
306 #[test]
307 fn join_is_monotonic_max() {
308 use QueueItemLifecycle::*;
309 assert_eq!(Live.join(Live), Live);
310 assert_eq!(Live.join(Struck), Struck);
311 assert_eq!(Struck.join(Live), Struck, "struck never regresses to live");
312 assert_eq!(Struck.join(Struck), Struck);
313 }
314
315 #[test]
316 fn join_is_idempotent_and_commutative() {
317 use QueueItemLifecycle::*;
318 for &a in &[Live, Struck] {
319 assert_eq!(a.join(a), a, "idempotent");
320 for &b in &[Live, Struck] {
321 assert_eq!(a.join(b), b.join(a), "commutative");
322 }
323 }
324 }
325
326 #[test]
327 fn classify_detects_strike_on_head_line() {
328 assert_eq!(
329 QueueItemLifecycle::classify("- do [#abcd] fix"),
330 QueueItemLifecycle::Live
331 );
332 assert_eq!(
333 QueueItemLifecycle::classify("- ~~do [#abcd] fix~~"),
334 QueueItemLifecycle::Struck
335 );
336 assert_eq!(
337 QueueItemLifecycle::classify("- ~~do [#abcd] fix~~\n note line"),
338 QueueItemLifecycle::Struck
339 );
340 assert_eq!(
341 QueueItemLifecycle::classify("- do [#abcd] fix\n ~~old note~~"),
342 QueueItemLifecycle::Live
343 );
344 }
345
346 #[test]
347 fn queue_item_state_rank_is_strictly_ascending_lifecycle_order() {
348 for pair in ALL_STATES.windows(2) {
349 assert!(pair[0].rank() < pair[1].rank());
350 assert!(pair[0] < pair[1]);
351 }
352 }
353
354 #[test]
355 fn queue_item_transition_is_total_monotonic_and_idempotent() {
356 for &state in &ALL_STATES {
357 for &event in &ALL_EVENTS {
358 let once = t(state, event);
359 let twice = t(once, event);
360 assert!(once.rank() >= state.rank());
361 assert_eq!(once, twice);
362 if event.target().rank() > state.rank() {
363 assert_eq!(once, event.target());
364 } else {
365 assert_eq!(once, state);
366 }
367 }
368 }
369 }
370
371 #[test]
372 fn queue_item_reaped_is_absorbing() {
373 for &event in &ALL_EVENTS {
374 assert_eq!(t(QueueItemState::Reaped, event), QueueItemState::Reaped);
375 }
376 assert!(QueueItemState::Reaped.is_terminal());
377 }
378
379 #[test]
380 fn queue_item_machine_drives_canonical_lifecycle() {
381 let machine = QueueItemMachine::new(QueueItemState::Authored);
382 assert_eq!(machine.state(), QueueItemState::Authored);
383
384 assert!(machine.send(QueueItemEvent::BacklogMirrored));
385 assert_eq!(machine.state(), QueueItemState::Mirrored);
386 assert!(machine.send(QueueItemEvent::DrainStarted));
387 assert_eq!(machine.state(), QueueItemState::InProgress);
388 assert!(machine.send(QueueItemEvent::ExchangeAnswered));
389 assert_eq!(machine.state(), QueueItemState::Answered);
390 assert!(machine.send(QueueItemEvent::StruckThrough));
391 assert_eq!(machine.state(), QueueItemState::Struck);
392 assert!(machine.send(QueueItemEvent::Reaped));
393 assert_eq!(machine.state(), QueueItemState::Reaped);
394 }
395
396 #[test]
397 fn queue_item_stale_reemit_storm_never_resurrects_an_advanced_item() {
398 let machine = QueueItemMachine::new(QueueItemState::Authored);
399 machine.send(QueueItemEvent::BacklogMirrored);
400 machine.send(QueueItemEvent::DrainStarted);
401 machine.send(QueueItemEvent::ExchangeAnswered);
402 assert_eq!(machine.state(), QueueItemState::Answered);
403
404 for _ in 0..10 {
405 machine.send(QueueItemEvent::OperatorAuthored);
406 machine.send(QueueItemEvent::BacklogMirrored);
407 }
408 assert_eq!(machine.state(), QueueItemState::Answered);
409 }
410
411 #[test]
412 fn queue_item_lifecycle_projection_commutes_with_join() {
413 for &a in &ALL_STATES {
414 for &b in &ALL_STATES {
415 let joined_state = if a.rank() >= b.rank() { a } else { b };
416 assert_eq!(joined_state.lifecycle(), a.lifecycle().join(b.lifecycle()));
417 }
418 }
419 }
420
421 #[test]
422 fn queue_item_identity_collapses_pin_and_prefix_variants() {
423 let bracketed = QueueItemIdentity::from_prompt(":pushpin: do [#gww8] thing");
424 let bare_prefix = QueueItemIdentity::from_prompt("do [#gww8] thing");
425 let plain = QueueItemIdentity::from_prompt("[#gww8]");
426 assert_eq!(bracketed, QueueItemIdentity::Id("gww8".into()));
427 assert_eq!(bracketed, bare_prefix);
428 assert_eq!(bracketed, plain);
429 assert!(bracketed.is_id_backed());
430 }
431
432 #[test]
433 fn queue_item_free_text_identity_keys_on_normalized_text() {
434 let a = QueueItemIdentity::from_prompt("Still getting JB File Cache Conflict dialogs.");
435 let b = QueueItemIdentity::from_prompt("still getting jb file cache conflict dialogs.");
436 let other = QueueItemIdentity::from_prompt("Opencode reverse-orders numeric lists.");
437 assert_eq!(a, b);
438 assert_ne!(a, other);
439 assert!(!a.is_id_backed());
440 }
441}