1use crate::output::{ansi, one_line, sanitize_untrusted};
10use kranz_engine::event_log::EventLog;
11use kranz_engine::events::{Event, EventKind};
12use kranz_engine::types::{GrantKind, MissionState, Role, RunResult};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18
19pub const POLL_INTERVAL: Duration = Duration::from_millis(300);
21
22const LINE_MAX: usize = 160;
24
25struct RunTag {
28 role: Role,
29 feature_id: Option<String>,
30 milestone_id: Option<String>,
31}
32
33pub struct EventRenderer {
35 color: bool,
36 runs: HashMap<String, RunTag>,
37 suppress_text: bool,
41}
42
43impl EventRenderer {
44 pub fn new(color: bool) -> Self {
45 EventRenderer {
46 color,
47 runs: HashMap::new(),
48 suppress_text: false,
49 }
50 }
51
52 pub fn planning(state: &MissionState, color: bool) -> Self {
54 let mut renderer = Self::seeded(state, color);
55 renderer.suppress_text = true;
56 renderer
57 }
58
59 pub fn seeded(state: &MissionState, color: bool) -> Self {
62 let mut renderer = Self::new(color);
63 for run in state.runs.values() {
64 renderer.runs.insert(
65 run.id.clone(),
66 RunTag {
67 role: run.role,
68 feature_id: run.feature_id.clone(),
69 milestone_id: run.milestone_id.clone(),
70 },
71 );
72 }
73 renderer
74 }
75
76 fn run_tag(&self, run_id: &str) -> (String, &'static str) {
78 match self.runs.get(run_id) {
79 Some(tag) => match tag.role {
80 Role::Orchestrator => ("orch".to_string(), ansi::CYAN),
81 Role::Worker => (
82 match &tag.feature_id {
83 Some(feature) => format!("worker {feature}"),
84 None => "worker".to_string(),
85 },
86 ansi::GREEN,
87 ),
88 Role::ValidatorScrutiny => (
89 scoped("validator-scrutiny", &tag.milestone_id),
90 ansi::MAGENTA,
91 ),
92 Role::ValidatorFunctional => (
93 scoped("validator-functional", &tag.milestone_id),
94 ansi::MAGENTA,
95 ),
96 },
97 None => ("worker".to_string(), ansi::GREEN),
98 }
99 }
100
101 pub fn render(&mut self, event: &Event) -> String {
103 let (tag, color, body) = match &event.kind {
104 EventKind::MissionCreated { goal, .. } => (
105 "mission".to_string(),
106 ansi::YELLOW,
107 format!("created: {goal}"),
108 ),
109 EventKind::PlanApproved { plan, .. } => {
110 let features: usize = plan.milestones.iter().map(|m| m.features.len()).sum();
111 (
112 "mission".to_string(),
113 ansi::YELLOW,
114 format!(
115 "plan approved ({} milestone(s), {features} feature(s))",
116 plan.milestones.len()
117 ),
118 )
119 }
120 EventKind::PlanRevisionProposed { revision, .. } => (
121 "mission".to_string(),
122 ansi::YELLOW,
123 format!("revision {revision} proposed; awaiting approval"),
124 ),
125 EventKind::PlanRevised { revision, .. } => (
126 "mission".to_string(),
127 ansi::YELLOW,
128 format!("revision {revision} approved"),
129 ),
130 EventKind::PlanRevisionRejected { revision, .. } => (
131 "mission".to_string(),
132 ansi::YELLOW,
133 format!("revision {revision} rejected"),
134 ),
135 EventKind::GrantRequested {
136 milestone_id,
137 kind,
138 command,
139 } => (
140 format!("milestone {milestone_id}"),
141 ansi::YELLOW,
142 format!(
143 "{} grant requested: `{command}`; awaiting approval",
144 grant_kind_label(kind)
145 ),
146 ),
147 EventKind::GrantApproved { kind, command } => (
148 "mission".to_string(),
149 ansi::YELLOW,
150 format!("{} grant approved: `{command}`", grant_kind_label(kind)),
151 ),
152 EventKind::GrantDenied {
153 kind,
154 command,
155 reason,
156 } => (
157 "mission".to_string(),
158 ansi::YELLOW,
159 format!(
160 "{} grant denied: `{command}` ({reason})",
161 grant_kind_label(kind)
162 ),
163 ),
164 EventKind::QuestionOpened {
165 question_id,
166 text,
167 options,
168 ..
169 } => (
170 "mission".to_string(),
171 ansi::YELLOW,
172 if options.is_empty() {
173 format!("question {question_id} opened: {text} (free-text answer)")
174 } else {
175 format!(
176 "question {question_id} opened: {text} ({} option(s)); awaiting an answer",
177 options.len()
178 )
179 },
180 ),
181 EventKind::QuestionAnswered {
182 question_id,
183 answer,
184 ..
185 } => (
186 "mission".to_string(),
187 ansi::YELLOW,
188 format!("question {question_id} answered: {answer}"),
189 ),
190 EventKind::QuestionCleared { question_id, why } => (
191 "mission".to_string(),
192 ansi::DIM,
193 format!("question {question_id} cleared ({why})"),
194 ),
195 EventKind::MilestoneStarted { milestone_id, .. } => (
196 format!("milestone {milestone_id}"),
197 ansi::YELLOW,
198 "started".to_string(),
199 ),
200 EventKind::FeatureStarted { feature_id } => (
201 format!("feature {feature_id}"),
202 ansi::BLUE,
203 "started".to_string(),
204 ),
205 EventKind::FeatureProgress {
206 feature_id,
207 commits,
208 ..
209 } => (
210 format!("feature {feature_id}"),
211 ansi::DIM,
212 format!("recorded {} cumulative commit(s)", commits.len()),
213 ),
214 EventKind::WorkerSpawned {
215 run_id,
216 role,
217 feature_id,
218 milestone_id,
219 model,
220 ..
221 } => {
222 self.runs.insert(
223 run_id.clone(),
224 RunTag {
225 role: *role,
226 feature_id: feature_id.clone(),
227 milestone_id: milestone_id.clone(),
228 },
229 );
230 let (tag, color) = self.run_tag(run_id);
231 (tag, color, format!("spawned ({model})"))
232 }
233 EventKind::WorkerMessage {
234 run_id,
235 tag: kind,
236 content,
237 } => {
238 if self.suppress_text && kind == "text" {
239 return String::new();
240 }
241 let (tag, mut color) = self.run_tag(run_id);
242 let body = match kind.as_str() {
243 "text" => content.clone(),
244 "denied" => {
245 color = ansi::RED;
246 format!("DENIED: {content}")
247 }
248 other => format!("{other}: {content}"),
249 };
250 (tag, color, body)
251 }
252 EventKind::WorkerEgressDenied {
253 run_id,
254 denials,
255 omitted_count,
256 } => {
257 let (tag, _) = self.run_tag(run_id);
258 let first = denials
259 .first()
260 .map(|denial| format!("{}:{}", denial.host, denial.port))
261 .unwrap_or_else(|| "destination unavailable".to_string());
262 let more = (denials.len().saturating_sub(1) as u64).saturating_add(*omitted_count);
263 let suffix = if more > 0 {
264 format!(" (+{more} additional record(s))")
265 } else {
266 String::new()
267 };
268 (tag, ansi::RED, format!("EGRESS DENIED: {first}{suffix}"))
269 }
270 EventKind::WorkerCompleted {
271 run_id,
272 result,
273 cost_usd,
274 ..
275 } => {
276 let (tag, color) = self.run_tag(run_id);
277 let cost = cost_usd.map(|c| format!(" (${c:.2})")).unwrap_or_default();
278 (
279 tag,
280 color,
281 format!("completed: {}{cost}", result_label(*result)),
282 )
283 }
284 EventKind::FeatureCompleted {
285 feature_id,
286 commits,
287 } => (
288 format!("feature {feature_id}"),
289 ansi::BLUE,
290 format!("complete ({} commit(s))", commits.len()),
291 ),
292 EventKind::FeatureFailed {
293 feature_id, reason, ..
294 } => (
295 format!("feature {feature_id}"),
296 ansi::RED,
297 format!("FAILED: {reason}"),
298 ),
299 EventKind::FeatureSkipped { feature_id, reason } => (
300 format!("feature {feature_id}"),
301 ansi::BLUE,
302 format!("skipped: {reason}"),
303 ),
304 EventKind::MilestoneValidating { milestone_id } => (
305 format!("milestone {milestone_id}"),
306 ansi::YELLOW,
307 "validating".to_string(),
308 ),
309 EventKind::ValidationFinding {
310 milestone_id,
311 finding,
312 ..
313 } => (
314 format!("milestone {milestone_id}"),
315 ansi::YELLOW,
316 format!(
317 "finding [{}] {}: {}",
318 finding.severity, finding.subject, finding.evidence
319 ),
320 ),
321 EventKind::ValidatorTamper {
322 milestone_id,
323 head_before,
324 head_after,
325 appeared,
326 resolved,
327 git_metadata_changed,
328 ..
329 } => {
330 let mut what: Vec<String> = appeared.iter().take(3).cloned().collect();
331 if head_before != head_after {
332 what.push("HEAD moved".to_string());
333 }
334 if *git_metadata_changed {
335 what.push(".git metadata".to_string());
336 }
337 if !resolved.is_empty() {
338 what.push(format!("{} entr(ies) hidden", resolved.len()));
339 }
340 (
341 format!("milestone {milestone_id}"),
342 ansi::RED,
343 format!("validator TAMPER: {}", what.join("; ")),
344 )
345 }
346 EventKind::ValidationSnapshot {
347 milestone_id,
348 target_tier,
349 detail,
350 ..
351 } => (
352 format!("milestone {milestone_id}"),
353 ansi::DIM,
354 match detail {
355 Some(detail) => {
356 format!("validator snapshot (target: {target_tier}) — {detail}")
357 }
358 None => format!("validator snapshot (target: {target_tier})"),
359 },
360 ),
361 EventKind::ValidationConfirm {
362 milestone_id,
363 confirmed,
364 disagreements,
365 ..
366 } => (
367 format!("milestone {milestone_id}"),
368 if disagreements.is_empty() {
369 ansi::DIM
370 } else {
371 ansi::YELLOW
372 },
373 if disagreements.is_empty() {
374 format!(
375 "local validator PASS frontier-confirmed ({} check(s))",
376 confirmed.len()
377 )
378 } else {
379 format!(
380 "local validator MISS: frontier overturned {} PASS(es): {}",
381 disagreements.len(),
382 disagreements
383 .iter()
384 .map(|f| f.subject.as_str())
385 .collect::<Vec<_>>()
386 .join(", ")
387 )
388 },
389 ),
390 EventKind::ValidationPtyTranscript {
391 milestone_id,
392 assertion_id,
393 verdict,
394 ..
395 } => (
396 format!("milestone {milestone_id}"),
397 match verdict {
398 kranz_engine::gate::GateVerdict::Pass => ansi::DIM,
399 kranz_engine::gate::GateVerdict::Fail => ansi::YELLOW,
400 },
401 format!(
402 "pty validation [{assertion_id}] {} (transcript artifact)",
403 match verdict {
404 kranz_engine::gate::GateVerdict::Pass => "PASS",
405 kranz_engine::gate::GateVerdict::Fail => "FAIL",
406 }
407 ),
408 ),
409 EventKind::GateResult {
410 gate,
411 surface,
412 kind,
413 index,
414 verdict,
415 ..
416 } => (
417 "gate".to_string(),
418 match verdict {
419 kranz_engine::gate::GateVerdict::Pass => ansi::GREEN,
420 kranz_engine::gate::GateVerdict::Fail => ansi::YELLOW,
421 },
422 format!(
423 "{gate} ({}, {} #{index}) {}",
424 match surface {
425 kranz_engine::gate::GateSurface::Approval => "approval",
426 kranz_engine::gate::GateSurface::FinalGate => "final gate",
427 },
428 match kind {
429 kranz_engine::gate::GateKind::Deterministic => "det",
430 kranz_engine::gate::GateKind::ModelJudged => "model",
431 },
432 match verdict {
433 kranz_engine::gate::GateVerdict::Pass => "pass",
434 kranz_engine::gate::GateVerdict::Fail => "FAIL",
435 }
436 ),
437 ),
438 EventKind::HookGateFired {
439 gate,
440 tool,
441 subject,
442 verdict,
443 ..
444 } => (
445 "hook gate".to_string(),
446 if verdict == "blocked" {
447 ansi::YELLOW
448 } else {
449 ansi::DIM
450 },
451 format!("{gate}: {tool} {subject} {verdict} (in-process)"),
452 ),
453 EventKind::DivergenceNoted {
454 unit,
455 candidates,
456 diverged,
457 } => (
458 format!("feature {unit}"),
459 ansi::BLUE,
460 if *diverged {
463 format!("pool streams DIVERGED ({} candidates)", candidates.len())
464 } else {
465 format!(
466 "pool streams agreed ({} candidates) — logged, never trusted",
467 candidates.len()
468 )
469 },
470 ),
471 EventKind::DivergenceResolved {
472 unit,
473 selected,
474 decided_by,
475 ..
476 } => (
477 format!("feature {unit}"),
478 ansi::BLUE,
479 match selected {
480 Some(index) => format!("pool resolved → candidate c{index} (by {decided_by})"),
481 None => format!("pool resolved → no candidate (by {decided_by})"),
482 },
483 ),
484 EventKind::FixFeatureCreated {
485 milestone_id,
486 feature,
487 } => (
488 format!("milestone {milestone_id}"),
489 ansi::YELLOW,
490 format!("fix feature {}: {}", feature.id, feature.title),
491 ),
492 EventKind::TierEscalated {
493 milestone_id,
494 from,
495 to,
496 reason,
497 } => (
498 format!("milestone {milestone_id}"),
499 ansi::YELLOW,
500 format!("escalated {from:?} -> {to:?}: {reason}"),
501 ),
502 EventKind::WorkerEscalated {
503 feature_id,
504 from,
505 to,
506 reason,
507 ..
508 } => (
509 format!("feature {feature_id}"),
510 ansi::YELLOW,
511 format!("worker asked the frontier advisor ({from:?} -> {to:?}): {reason}"),
512 ),
513 EventKind::MilestoneBlocked {
514 milestone_id,
515 reason,
516 ..
517 } => (
518 format!("milestone {milestone_id}"),
519 ansi::RED,
520 format!("BLOCKED: {reason}"),
521 ),
522 EventKind::MilestoneUnblocked {
523 milestone_id,
524 reason,
525 ..
526 } => (
527 format!("milestone {milestone_id}"),
528 ansi::YELLOW,
529 format!("unblocked: {reason}"),
530 ),
531 EventKind::MilestoneCompleted { milestone_id, tag } => {
532 let tag_note = tag
533 .as_deref()
534 .map(|t| format!(" (tag {t})"))
535 .unwrap_or_default();
536 (
537 format!("milestone {milestone_id}"),
538 ansi::YELLOW,
539 format!("complete{tag_note}"),
540 )
541 }
542 EventKind::MissionValidating {} => (
543 "mission".to_string(),
544 ansi::YELLOW,
545 "final contract gate".to_string(),
546 ),
547 EventKind::MissionPaused {} => {
548 ("mission".to_string(), ansi::YELLOW, "paused".to_string())
549 }
550 EventKind::MissionResumed {} => {
551 ("mission".to_string(), ansi::YELLOW, "resumed".to_string())
552 }
553 EventKind::UserMessage { text, interrupt } => (
554 "user".to_string(),
555 ansi::MAGENTA,
556 if *interrupt {
557 format!("(interrupt) {text}")
558 } else {
559 text.clone()
560 },
561 ),
562 EventKind::OrchestratorDecision { summary, .. } => (
563 "orch".to_string(),
564 ansi::CYAN,
565 format!("decision: {summary}"),
566 ),
567 EventKind::SecretRedacted {
568 rule_id,
569 fingerprint,
570 location,
571 } => (
572 "secret".to_string(),
573 ansi::YELLOW,
574 format!("redacted {rule_id} {fingerprint} at {location}"),
575 ),
576 EventKind::ConfigChanged { .. } => (
577 "mission".to_string(),
578 ansi::YELLOW,
579 "config changed".to_string(),
580 ),
581 EventKind::MissionCompleted {} => {
582 ("mission".to_string(), ansi::GREEN, "COMPLETE".to_string())
583 }
584 EventKind::MissionFailed { reason } => (
585 "mission".to_string(),
586 ansi::RED,
587 format!("FAILED: {reason}"),
588 ),
589 EventKind::MissionAbandoned { reason } => (
590 "mission".to_string(),
591 ansi::DIM,
592 format!("ABANDONED: {reason}"),
593 ),
594 EventKind::WorkspaceProvisioned {
595 provider,
596 cwd,
597 detail,
598 ..
599 } => (
600 "workspace".to_string(),
601 ansi::BLUE,
602 match detail {
603 Some(detail) => format!("provisioned ({provider}): {cwd} — {detail}"),
604 None => format!("provisioned ({provider}): {cwd}"),
605 },
606 ),
607 EventKind::WorkspaceReadinessReport { outcome, detail } => (
608 "workspace".to_string(),
609 if outcome == "ready" {
610 ansi::GREEN
611 } else {
612 ansi::RED
613 },
614 match detail {
615 Some(detail) => format!("readiness {outcome}: {detail}"),
616 None => format!("readiness {outcome}"),
617 },
618 ),
619 EventKind::WorkspaceTeardown { mode, state } => (
620 "workspace".to_string(),
621 ansi::DIM,
622 match state {
626 Some(state) => format!("teardown ({mode}): {state}"),
627 None => format!("teardown ({mode})"),
628 },
629 ),
630 EventKind::WorkspaceProviderPinned {
631 provider,
632 template,
633 version,
634 } => (
635 "workspace".to_string(),
636 ansi::BLUE,
637 format!(
638 "provider pinned: {provider} · {template} · {}",
639 if provider == "remote" {
640 format!("adapter {version}")
643 } else if version == "none" {
644 "no contract".to_string()
645 } else {
646 format!("contract v{version}")
647 }
648 ),
649 ),
650 EventKind::StandardsResolved {
651 pack_name,
652 digest,
653 rules,
654 ..
655 } => (
656 "standards".to_string(),
657 ansi::BLUE,
658 format!(
659 "resolved: pack {pack_name} · {} rule(s) pinned · sha256:{digest}",
660 rules.len()
661 ),
662 ),
663 EventKind::StandardsDrifted { changed_rules, .. } => (
664 "standards".to_string(),
665 ansi::RED,
666 format!(
667 "policy drift: merge refused ({} change(s) to the applicable enforced set)",
668 changed_rules.len()
669 ),
670 ),
671 EventKind::StandardsWaiverApproved {
672 rule_id,
673 rule_revision,
674 approver,
675 ..
676 } => (
677 "standards".to_string(),
678 ansi::BLUE,
679 format!("waiver approved: {rule_id} r{rule_revision} by {approver}"),
680 ),
681 EventKind::StandardsAttestationApproved {
682 rule_id,
683 rule_revision,
684 approver,
685 ..
686 } => (
687 "standards".to_string(),
688 ansi::BLUE,
689 format!("attestation approved: {rule_id} r{rule_revision} by {approver}"),
690 ),
691 };
692
693 let tag = sanitize_untrusted(&tag);
698 let budget = LINE_MAX.saturating_sub(tag.chars().count() + 3);
699 let body = one_line(&body, budget);
700 if self.color {
701 format!("{color}[{tag}]{reset} {body}", reset = ansi::RESET)
702 } else {
703 format!("[{tag}] {body}")
704 }
705 }
706}
707
708fn grant_kind_label(kind: &GrantKind) -> &'static str {
710 match kind {
711 GrantKind::Command => "command",
712 GrantKind::TouchPath => "touch-set",
713 GrantKind::WorkerDeny => "deny-lift",
714 GrantKind::Egress => "egress",
715 }
716}
717
718fn scoped(base: &str, milestone_id: &Option<String>) -> String {
719 match milestone_id {
720 Some(id) => format!("{base} {id}"),
721 None => base.to_string(),
722 }
723}
724
725fn result_label(result: RunResult) -> &'static str {
726 match result {
727 RunResult::Pass => "pass",
728 RunResult::Fail => "fail",
729 RunResult::Partial => "partial",
730 }
731}
732
733pub async fn tail_events(
739 events_path: PathBuf,
740 from_seq: u64,
741 mut renderer: EventRenderer,
742 stop: Arc<AtomicBool>,
743) {
744 let mut last_seq = from_seq;
745 loop {
746 let stopping = stop.load(Ordering::Relaxed);
747 match EventLog::read_events_after(&events_path, last_seq) {
748 Ok(events) => {
749 for event in &events {
750 eprintln!("{}", renderer.render(event));
751 last_seq = event.seq;
752 }
753 }
754 Err(error) => {
755 tracing::debug!(%error, "event tail read failed (transient)");
756 }
757 }
758 if stopping {
759 return;
760 }
761 tokio::time::sleep(POLL_INTERVAL).await;
762 }
763}