1use std::cell::RefCell;
20use std::collections::{BTreeMap, BTreeSet};
21use std::sync::Arc;
22
23use serde_json::Value;
24
25use crate::event_log::{EventLog, LogEvent, Topic};
26use crate::value::VmClosure;
27
28pub const LIFECYCLE_AUDIT_TOPIC: &str = "pipeline.lifecycle.audit";
29
30thread_local! {
31 static PIPELINE_ON_FINISH: RefCell<Option<Arc<VmClosure>>> = const { RefCell::new(None) };
32 static LIFECYCLE_AUDIT_LOG: RefCell<Vec<LifecycleAuditEntry>> = const { RefCell::new(Vec::new()) };
33 static PARTIAL_HANDOFF_REGISTRY: RefCell<Vec<PartialHandoffEnvelope>> = const { RefCell::new(Vec::new()) };
34 static PIPELINE_DISPOSITION: RefCell<Option<Value>> = const { RefCell::new(None) };
35 static LIFECYCLE_SEQ: RefCell<u64> = const { RefCell::new(0) };
36}
37
38#[derive(Clone, Default)]
39struct PipelineLifecycleState {
40 on_finish: Option<Arc<VmClosure>>,
41 audit_log: Vec<LifecycleAuditEntry>,
42 partial_handoffs: Vec<PartialHandoffEnvelope>,
43 disposition: Option<Value>,
44 seq: u64,
45}
46
47fn swap_pipeline_lifecycle_state(state: PipelineLifecycleState) -> PipelineLifecycleState {
48 PipelineLifecycleState {
49 on_finish: PIPELINE_ON_FINISH
50 .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.on_finish)),
51 audit_log: LIFECYCLE_AUDIT_LOG
52 .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.audit_log)),
53 partial_handoffs: PARTIAL_HANDOFF_REGISTRY
54 .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.partial_handoffs)),
55 disposition: PIPELINE_DISPOSITION
56 .with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.disposition)),
57 seq: LIFECYCLE_SEQ.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), state.seq)),
58 }
59}
60
61pub(crate) fn checkpoint_pipeline_lifecycle() -> PipelineLifecycleCheckpoint {
67 let current = swap_pipeline_lifecycle_state(PipelineLifecycleState::default());
68 let outer = current.clone();
69 let _ = swap_pipeline_lifecycle_state(current);
70 PipelineLifecycleCheckpoint { outer: Some(outer) }
71}
72
73pub(crate) struct PipelineLifecycleCheckpoint {
74 outer: Option<PipelineLifecycleState>,
75}
76
77impl PipelineLifecycleCheckpoint {
78 pub(crate) fn complete(mut self) {
79 self.outer = None;
80 }
81}
82
83impl Drop for PipelineLifecycleCheckpoint {
84 fn drop(&mut self) {
85 if let Some(outer) = self.outer.take() {
86 let _ = swap_pipeline_lifecycle_state(outer);
87 }
88 }
89}
90
91pub fn set_pipeline_on_finish(callback: Arc<VmClosure>) {
94 PIPELINE_ON_FINISH.with(|slot| *slot.borrow_mut() = Some(callback));
95}
96
97pub fn take_pipeline_on_finish() -> Option<Arc<VmClosure>> {
100 PIPELINE_ON_FINISH.with(|slot| slot.borrow_mut().take())
101}
102
103pub fn clear_pipeline_on_finish() {
110 PIPELINE_ON_FINISH.with(|slot| *slot.borrow_mut() = None);
111 LIFECYCLE_AUDIT_LOG.with(|log| log.borrow_mut().clear());
112 PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow_mut().clear());
113 PIPELINE_DISPOSITION.with(|slot| *slot.borrow_mut() = None);
114 LIFECYCLE_SEQ.with(|seq| *seq.borrow_mut() = 0);
115}
116
117#[derive(Debug, Default, Clone)]
124pub struct UnsettledStateSnapshot {
125 pub suspended_subagents: Vec<Value>,
126 pub queued_triggers: Vec<Value>,
127 pub partial_handoffs: Vec<Value>,
128 pub in_flight_llm_calls: Vec<Value>,
129 pub pool_pending_tasks: Vec<Value>,
130}
131
132impl UnsettledStateSnapshot {
133 pub fn is_empty(&self) -> bool {
134 self.suspended_subagents.is_empty()
135 && self.queued_triggers.is_empty()
136 && self.partial_handoffs.is_empty()
137 && self.in_flight_llm_calls.is_empty()
138 && self.pool_pending_tasks.is_empty()
139 }
140
141 pub fn to_json(&self) -> Value {
142 serde_json::json!({
143 "suspended_subagents": self.suspended_subagents,
144 "queued_triggers": self.queued_triggers,
145 "partial_handoffs": self.partial_handoffs,
146 "in_flight_llm_calls": self.in_flight_llm_calls,
147 "pool_pending_tasks": self.pool_pending_tasks,
148 })
149 }
150
151 pub fn counts_json(&self) -> Value {
152 serde_json::json!({
153 "suspended": self.suspended_subagents.len(),
154 "queued": self.queued_triggers.len(),
155 "partial": self.partial_handoffs.len(),
156 "in_flight": self.in_flight_llm_calls.len(),
157 "pool_pending": self.pool_pending_tasks.len(),
158 })
159 }
160
161 pub fn summary(&self) -> String {
162 let suspended = self.suspended_subagents.len();
163 let queued = self.queued_triggers.len();
164 let partial = self.partial_handoffs.len();
165 let in_flight = self.in_flight_llm_calls.len();
166 let pool_pending = self.pool_pending_tasks.len();
167 if suspended == 0 && queued == 0 && partial == 0 && in_flight == 0 && pool_pending == 0 {
168 "no unsettled work".to_string()
169 } else {
170 format!(
171 "unsettled work: {suspended} suspended subagents, {queued} queued triggers, {partial} partial handoffs, {in_flight} in-flight llm calls, {pool_pending} pool pending tasks"
172 )
173 }
174 }
175}
176
177pub fn unsettled_state_snapshot() -> UnsettledStateSnapshot {
182 unsettled_state_snapshot_base(Vec::new())
183}
184
185pub async fn unsettled_state_snapshot_async() -> UnsettledStateSnapshot {
188 unsettled_state_snapshot_base(queued_trigger_snapshot_json().await)
189}
190
191fn unsettled_state_snapshot_base(queued_triggers: Vec<Value>) -> UnsettledStateSnapshot {
192 UnsettledStateSnapshot {
193 suspended_subagents: crate::stdlib::agents::snapshot_suspended_subagents(),
194 queued_triggers,
195 partial_handoffs: partial_handoff_snapshot_json(),
196 in_flight_llm_calls: crate::llm::snapshot_in_flight_llm_calls(),
197 pool_pending_tasks: crate::stdlib::pool::snapshot_pending_tasks(),
198 }
199}
200
201#[derive(Debug, Clone)]
205pub struct LifecycleAuditEntry {
206 pub seq: u64,
207 pub kind: String,
208 pub payload: Value,
209 pub pipeline_id: Option<String>,
210}
211
212impl LifecycleAuditEntry {
213 pub fn to_json(&self) -> Value {
214 serde_json::json!({
215 "seq": self.seq,
216 "kind": self.kind,
217 "payload": self.payload,
218 "pipeline_id": self.pipeline_id,
219 })
220 }
221}
222
223pub fn record_lifecycle_audit(kind: impl Into<String>, payload: Value) -> LifecycleAuditEntry {
226 let entry = LifecycleAuditEntry {
227 seq: next_seq(),
228 kind: kind.into(),
229 payload,
230 pipeline_id: crate::orchestration::current_mutation_session()
231 .and_then(|session| session.run_id.or(Some(session.session_id))),
232 };
233 LIFECYCLE_AUDIT_LOG.with(|log| log.borrow_mut().push(entry.clone()));
234 persist_lifecycle_audit_entry(&entry);
235 entry
236}
237
238pub fn take_lifecycle_audit_log() -> Vec<LifecycleAuditEntry> {
241 LIFECYCLE_AUDIT_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
242}
243
244pub fn lifecycle_audit_log_snapshot() -> Vec<LifecycleAuditEntry> {
246 LIFECYCLE_AUDIT_LOG.with(|log| log.borrow().clone())
247}
248
249#[derive(Debug, Clone)]
253pub struct PartialHandoffEnvelope {
254 pub envelope_id: String,
255 pub target_pipeline: String,
256 pub origin_pipeline: Option<String>,
257 pub payload: Value,
258 pub seq: u64,
259 pub queued_at_ms: i64,
260}
261
262impl PartialHandoffEnvelope {
263 pub fn to_json(&self) -> Value {
264 self.to_json_at(crate::stdlib::clock::now_wall_ms())
265 }
266
267 pub fn to_json_at(&self, now_ms: i64) -> Value {
268 serde_json::json!({
269 "envelope_id": self.envelope_id,
270 "from": self.origin_pipeline,
271 "to": self.target_pipeline,
272 "payload_summary": payload_summary(&self.payload),
273 "queued_at_ms": self.queued_at_ms,
274 "age_ms": now_ms.saturating_sub(self.queued_at_ms).max(0),
275 "target_pipeline": self.target_pipeline,
276 "origin_pipeline": self.origin_pipeline,
277 "payload": self.payload,
278 "seq": self.seq,
279 })
280 }
281}
282
283pub fn record_partial_handoff(
287 target_pipeline: impl Into<String>,
288 payload: Value,
289) -> PartialHandoffEnvelope {
290 let seq = next_seq();
291 let envelope = PartialHandoffEnvelope {
292 envelope_id: format!("envelope_{seq}"),
293 target_pipeline: target_pipeline.into(),
294 origin_pipeline: crate::orchestration::current_mutation_session()
295 .and_then(|session| session.run_id.or(Some(session.session_id))),
296 payload,
297 seq,
298 queued_at_ms: crate::stdlib::clock::now_wall_ms(),
299 };
300 PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow_mut().push(envelope.clone()));
301 envelope
302}
303
304pub fn acknowledge_partial_handoff(
307 envelope_id: &str,
308 decision: Value,
309) -> Option<PartialHandoffEnvelope> {
310 let removed = PARTIAL_HANDOFF_REGISTRY.with(|reg| {
311 let mut reg = reg.borrow_mut();
312 let index = reg
313 .iter()
314 .position(|entry| entry.envelope_id == envelope_id)?;
315 Some(reg.remove(index))
316 })?;
317 record_lifecycle_audit(
318 "handoff_acknowledged",
319 serde_json::json!({
320 "envelope_id": envelope_id,
321 "decision": decision,
322 }),
323 );
324 Some(removed)
325}
326
327pub fn finalize_pipeline_disposition(disposition: Value) -> Value {
328 PIPELINE_DISPOSITION.with(|slot| *slot.borrow_mut() = Some(disposition.clone()));
329 let entry = record_lifecycle_audit(
330 "pipeline_finalized",
331 serde_json::json!({
332 "disposition": disposition,
333 }),
334 );
335 serde_json::json!({
336 "status": "finalized",
337 "method": "finalize",
338 "entry": entry.to_json(),
339 })
340}
341
342pub fn pipeline_disposition_snapshot() -> Option<Value> {
343 PIPELINE_DISPOSITION.with(|slot| slot.borrow().clone())
344}
345
346fn partial_handoff_snapshot_json() -> Vec<Value> {
347 let now_ms = crate::stdlib::clock::now_wall_ms();
348 PARTIAL_HANDOFF_REGISTRY.with(|reg| reg.borrow().iter().map(|e| e.to_json_at(now_ms)).collect())
349}
350
351fn next_seq() -> u64 {
352 LIFECYCLE_SEQ.with(|seq| {
353 let mut slot = seq.borrow_mut();
354 *slot += 1;
355 *slot
356 })
357}
358
359fn persist_lifecycle_audit_entry(entry: &LifecycleAuditEntry) {
360 let Some(log) = crate::event_log::active_event_log() else {
361 return;
362 };
363 let Ok(topic) = Topic::new(LIFECYCLE_AUDIT_TOPIC) else {
364 return;
365 };
366 let mut headers = BTreeMap::new();
367 headers.insert("kind".to_string(), entry.kind.clone());
368 headers.insert("seq".to_string(), entry.seq.to_string());
369 if let Some(pipeline_id) = entry.pipeline_id.as_ref() {
370 headers.insert("pipeline_id".to_string(), pipeline_id.clone());
371 }
372 let _ = futures::executor::block_on(log.append(
373 &topic,
374 LogEvent::new("lifecycle_audit", entry.to_json()).with_headers(headers),
375 ));
376}
377
378async fn queued_trigger_snapshot_json() -> Vec<Value> {
379 let Some(log) = crate::event_log::active_event_log() else {
380 return Vec::new();
381 };
382 let now_ms = lifecycle_now_ms();
383 let mut out = Vec::new();
384 out.extend(snapshot_inbox_triggers(log.as_ref(), now_ms).await);
385 out.extend(snapshot_worker_queue_triggers(log, now_ms).await);
386 out.sort_by(|left, right| {
387 let left_key = (
388 left.get("queued_at_ms")
389 .and_then(Value::as_i64)
390 .unwrap_or(i64::MAX),
391 left.get("id").and_then(Value::as_str).unwrap_or_default(),
392 );
393 let right_key = (
394 right
395 .get("queued_at_ms")
396 .and_then(Value::as_i64)
397 .unwrap_or(i64::MAX),
398 right.get("id").and_then(Value::as_str).unwrap_or_default(),
399 );
400 left_key.cmp(&right_key)
401 });
402 out
403}
404
405fn lifecycle_now_ms() -> i64 {
406 crate::clock_mock::now_ms()
407}
408
409async fn snapshot_inbox_triggers(log: &crate::event_log::AnyEventLog, now_ms: i64) -> Vec<Value> {
410 let Ok(inbox_topic) = Topic::new(crate::triggers::TRIGGER_INBOX_ENVELOPES_TOPIC) else {
411 return Vec::new();
412 };
413 let Ok(outbox_topic) = Topic::new(crate::triggers::TRIGGER_OUTBOX_TOPIC) else {
414 return Vec::new();
415 };
416 let Ok(cancel_topic) = Topic::new(crate::triggers::TRIGGER_CANCEL_REQUESTS_TOPIC) else {
417 return Vec::new();
418 };
419 let inbox = log
420 .read_range(&inbox_topic, None, usize::MAX)
421 .await
422 .unwrap_or_default();
423 let outbox = log
424 .read_range(&outbox_topic, None, usize::MAX)
425 .await
426 .unwrap_or_default();
427 let cancels = log
428 .read_range(&cancel_topic, None, usize::MAX)
429 .await
430 .unwrap_or_default();
431
432 let completed_events = outbox
433 .into_iter()
434 .filter_map(|(_, event)| {
435 let event_id = event
436 .headers
437 .get("event_id")
438 .cloned()
439 .or_else(|| json_string(&event.payload, &["event_id"]))?;
440 let binding_key = event
441 .headers
442 .get("binding_key")
443 .cloned()
444 .or_else(|| json_string(&event.payload, &["binding_key"]))
445 .unwrap_or_default();
446 Some((binding_key, event_id))
447 })
448 .collect::<BTreeSet<_>>();
449 let cancelled_events = cancels
450 .into_iter()
451 .filter(|(_, event)| event.kind == "dispatch_cancel_requested")
452 .filter_map(|(_, event)| {
453 let event_id = json_string(&event.payload, &["event_id"])?;
454 let binding_key = json_string(&event.payload, &["binding_key"]).unwrap_or_default();
455 Some((binding_key, event_id))
456 })
457 .collect::<BTreeSet<_>>();
458
459 inbox
460 .into_iter()
461 .filter(|(_, event)| event.kind == "event_ingested")
462 .filter_map(|(_, event)| {
463 let trigger = event
464 .payload
465 .get("event")
466 .cloned()
467 .unwrap_or_else(|| event.payload.clone());
468 let event_id = json_string(&trigger, &["id"])?;
469 let binding_key = event
470 .headers
471 .get("binding_key")
472 .cloned()
473 .or_else(|| {
474 let trigger_id = event
475 .headers
476 .get("trigger_id")
477 .cloned()
478 .or_else(|| json_string(&event.payload, &["trigger_id"]))?;
479 let version = json_u64(&event.payload, &["binding_version"])?;
480 Some(format!("{trigger_id}@v{version}"))
481 });
482 if event_is_settled(&completed_events, binding_key.as_deref(), &event_id)
483 || event_is_settled(&cancelled_events, binding_key.as_deref(), &event_id)
484 {
485 return None;
486 }
487 let trigger_id = event
488 .headers
489 .get("trigger_id")
490 .cloned()
491 .or_else(|| json_string(&event.payload, &["trigger_id"]));
492 let queued_at_ms = event.occurred_at_ms;
493 let provider = json_string(&trigger, &["provider"]).unwrap_or_default();
494 let kind = json_string(&trigger, &["kind"]).unwrap_or_default();
495 let id = binding_key
496 .as_ref()
497 .map(|key| format!("trigger://{key}/{event_id}"))
498 .unwrap_or_else(|| format!("trigger://{event_id}"));
499 Some(serde_json::json!({
500 "id": id,
501 "event_id": event_id,
502 "trigger_id": trigger_id,
503 "binding_key": binding_key,
504 "spec_summary": trigger_spec_summary(provider.as_str(), kind.as_str(), trigger_id.as_deref()),
505 "queued_at_ms": queued_at_ms,
506 "age_ms": now_ms.saturating_sub(queued_at_ms).max(0),
507 "source": "trigger_inbox",
508 }))
509 })
510 .collect()
511}
512
513fn event_is_settled(
514 settled_events: &BTreeSet<(String, String)>,
515 binding_key: Option<&str>,
516 event_id: &str,
517) -> bool {
518 let scoped_key = binding_key.unwrap_or_default().to_string();
519 settled_events.contains(&(scoped_key, event_id.to_string()))
520 || settled_events.contains(&(String::new(), event_id.to_string()))
521}
522
523async fn snapshot_worker_queue_triggers(
524 log: std::sync::Arc<crate::event_log::AnyEventLog>,
525 now_ms: i64,
526) -> Vec<Value> {
527 let queue = crate::triggers::WorkerQueue::new(log);
528 let Ok(queues) = queue.known_queues().await else {
529 return Vec::new();
530 };
531 let mut out = Vec::new();
532 for queue_name in queues {
533 let Ok(state) = queue.queue_state(&queue_name).await else {
534 continue;
535 };
536 let queue_label = state.queue.clone();
537 for job in state.jobs {
538 if job.acked || job.purged {
539 continue;
540 }
541 let event_id = job.job.event.id.0.clone();
542 let id = format!("worker://{}/{}", queue_label, job.job_event_id);
543 out.push(serde_json::json!({
544 "id": id,
545 "event_id": event_id,
546 "trigger_id": job.job.trigger_id,
547 "binding_key": job.job.binding_key,
548 "spec_summary": trigger_spec_summary(
549 job.job.event.provider.0.as_str(),
550 job.job.event.kind.as_str(),
551 Some(job.job.trigger_id.as_str())
552 ),
553 "queued_at_ms": job.enqueued_at_ms,
554 "age_ms": now_ms.saturating_sub(job.enqueued_at_ms).max(0),
555 "source": "worker_queue",
556 "queue": queue_label.clone(),
557 "job_event_id": job.job_event_id,
558 "claimed": job.active_claim.is_some(),
559 }));
560 }
561 }
562 out
563}
564
565fn trigger_spec_summary(provider: &str, kind: &str, trigger_id: Option<&str>) -> String {
566 match (
567 trigger_id.filter(|id| !id.is_empty()),
568 provider.is_empty(),
569 kind.is_empty(),
570 ) {
571 (Some(id), false, false) => format!("{id}: {provider}.{kind}"),
572 (Some(id), _, _) => id.to_string(),
573 (None, false, false) => format!("{provider}.{kind}"),
574 (None, false, true) => provider.to_string(),
575 (None, true, false) => kind.to_string(),
576 (None, true, true) => "trigger event".to_string(),
577 }
578}
579
580fn json_string(value: &Value, path: &[&str]) -> Option<String> {
581 let mut cursor = value;
582 for key in path {
583 cursor = cursor.get(*key)?;
584 }
585 cursor.as_str().map(ToString::to_string)
586}
587
588fn json_u64(value: &Value, path: &[&str]) -> Option<u64> {
589 let mut cursor = value;
590 for key in path {
591 cursor = cursor.get(*key)?;
592 }
593 cursor.as_u64()
594}
595
596fn payload_summary(payload: &Value) -> String {
597 match payload {
598 Value::Null => "nil".to_string(),
599 Value::Bool(value) => value.to_string(),
600 Value::Number(value) => value.to_string(),
601 Value::String(value) => {
602 let mut chars = value.chars();
603 let preview: String = chars.by_ref().take(80).collect();
604 if chars.next().is_some() {
605 format!("{preview}...")
606 } else {
607 preview
608 }
609 }
610 Value::Array(items) => format!("list(len={})", items.len()),
611 Value::Object(map) => {
612 let keys = map.keys().take(6).cloned().collect::<Vec<_>>().join(",");
613 if map.len() > 6 {
614 format!("object(keys={keys},...)")
615 } else {
616 format!("object(keys={keys})")
617 }
618 }
619 }
620}