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