Skip to main content

kranz_cli/
tail.rs

1//! Live event printer for `kranz run`: a read-only tail of `events.jsonl`
2//! (never the lock — the engine is the single writer, §4.3).
3//!
4//! [`tail_events`] polls [`EventLog::read_events_after`] every
5//! [`POLL_INTERVAL`] from the pre-run head seq and prints one human line per
6//! event to stderr. [`EventRenderer`] does the event → line mapping and is
7//! separate so tests can assert on exact lines.
8
9use 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
19/// Tail poll interval.
20pub const POLL_INTERVAL: Duration = Duration::from_millis(300);
21
22/// Hard cap on a rendered line (visible characters, ANSI codes excluded).
23const LINE_MAX: usize = 160;
24
25/// Role/scope info remembered per run id so `worker.message` lines can be
26/// tagged `[worker f-1-2]`, `[orch]`, `[validator-scrutiny ms-1]`, ...
27struct RunTag {
28    role: Role,
29    feature_id: Option<String>,
30    milestone_id: Option<String>,
31}
32
33/// Renders one [`Event`] as one role-tagged, optionally colored, single line.
34pub struct EventRenderer {
35    color: bool,
36    runs: HashMap<String, RunTag>,
37    /// Planning REPL mode: orchestrator text replies are printed in full by
38    /// the REPL itself, so the tail suppresses `text` deltas and shows only
39    /// activity (tool use, results, denials, system notes).
40    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    /// Renderer for the planning REPL (see `suppress_text`).
53    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    /// Renderer pre-seeded with the runs already in `state`, so messages of
60    /// sessions spawned before this tail started still get proper tags.
61    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    /// `[tag]` + color for a run id; unknown runs degrade to a bare worker tag.
77    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    /// One event → one human line (role-tagged, truncated to [`LINE_MAX`]).
102    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                // The agreement wording carries the rule: logged, never
461                // trusted — the tail must not read as a green light.
462                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                // The outcome rides along when present (ticket
623                // workspace-idle-hibernate); old stateless lines render as
624                // before.
625                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                        // The remote kind pins the ADAPTER version, not a
641                        // contract schema (workspace-remote-coder-provider).
642                        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        // Budget: "[tag] body" must fit LINE_MAX visible chars. Both halves
694        // interpolate model-authored ids and prose, so both are sanitized
695        // before they reach the operator's terminal (H9); `one_line` already
696        // sanitizes, the tag needs it explicitly.
697        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
708/// Short label for a grant's kind, prefixed onto the tail line.
709fn 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
733/// Tail `events.jsonl` from `from_seq`, printing each new event as one line
734/// to stderr, until `stop` is set (a final catch-up read runs after that).
735///
736/// Read errors are treated as transient (the engine may be mid-write on
737/// another thread; a torn *final* line is already tolerated by the reader).
738pub 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}