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::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                // The agreement wording carries the rule: logged, never
534                // trusted — the tail must not read as a green light.
535                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                // The outcome rides along when present (ticket
696                // workspace-idle-hibernate); old stateless lines render as
697                // before.
698                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                        // The remote kind pins the ADAPTER version, not a
714                        // contract schema (workspace-remote-coder-provider).
715                        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        // Budget: "[tag] body" must fit LINE_MAX visible chars. Both halves
767        // interpolate model-authored ids and prose, so both are sanitized
768        // before they reach the operator's terminal (H9); `one_line` already
769        // sanitizes, the tag needs it explicitly.
770        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
781/// Short label for a grant's kind, prefixed onto the tail line.
782fn 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
806/// Tail `events.jsonl` from `from_seq`, printing each new event as one line
807/// to stderr, until `stop` is set (a final catch-up read runs after that).
808///
809/// Read errors are treated as transient (the engine may be mid-write on
810/// another thread; a torn *final* line is already tolerated by the reader).
811pub 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}