Skip to content

Commit ae28055

Browse files
branchseerclaude
andcommitted
feat: add --log=interleaved|labeled|grouped modes
Add three output modes controlled by the --log flag: - interleaved (default): streams output directly, inherits stdio for uncached tasks - labeled: prefixes each line with [pkg#task], always piped - grouped: buffers output per task, prints as a block on completion Refactor reporter system: - Remove all_ancestors_single_node from reporter traits and execution - Rename LabeledReporter to InterleavedReporter (it was always interleaved) - Add new LabeledReporter with line-prefixing LabeledWriter - Add GroupedReporter with buffering GroupedWriter - Extract SummaryReporter as a decorator that wraps any mode reporter - Add Clone to CacheStatus and related types for summary tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1726f2e commit ae28055

File tree

13 files changed

+951
-438
lines changed

13 files changed

+951
-438
lines changed

crates/vite_task/src/cli/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ use vite_task_graph::{TaskSpecifier, query::TaskQuery};
77
use vite_task_plan::plan_request::{CacheOverride, PlanOptions, QueryPlanRequest};
88
use vite_workspace::package_filter::{PackageQueryArgs, PackageQueryError};
99

10+
/// Controls how task output is displayed.
11+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
12+
pub enum LogMode {
13+
/// Output streams directly to the terminal as tasks produce it.
14+
#[default]
15+
Interleaved,
16+
/// Each line is prefixed with `[packageName#taskName]`.
17+
Labeled,
18+
/// Output is buffered per task and printed as a block after each task completes.
19+
Grouped,
20+
}
21+
1022
#[derive(Debug, Clone, clap::Subcommand)]
1123
pub enum CacheSubcommand {
1224
/// Clean up all the cache
@@ -35,6 +47,10 @@ pub struct RunFlags {
3547
/// Force caching off for all tasks and scripts.
3648
#[clap(long, conflicts_with = "cache")]
3749
pub no_cache: bool,
50+
51+
/// How task output is displayed.
52+
#[clap(long, default_value = "interleaved")]
53+
pub log: LogMode,
3854
}
3955

4056
impl RunFlags {

crates/vite_task/src/session/cache/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub struct ExecutionCache {
7373

7474
const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard();
7575

76-
#[derive(Debug, Serialize, Deserialize)]
76+
#[derive(Debug, Clone, Serialize, Deserialize)]
7777
#[expect(
7878
clippy::large_enum_variant,
7979
reason = "FingerprintMismatch contains SpawnFingerprint which is intentionally large; boxing would add unnecessary indirection for a short-lived enum"
@@ -93,7 +93,7 @@ pub enum InputChangeKind {
9393
Removed,
9494
}
9595

96-
#[derive(Debug, Serialize, Deserialize)]
96+
#[derive(Debug, Clone, Serialize, Deserialize)]
9797
pub enum FingerprintMismatch {
9898
/// Found a previous cache entry key for the same task, but the spawn fingerprint differs.
9999
/// This happens when the command itself or an env changes.

crates/vite_task/src/session/event.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub enum ExecutionError {
4545
PostRunFingerprint(#[source] anyhow::Error),
4646
}
4747

48-
#[derive(Debug)]
48+
#[derive(Debug, Clone)]
4949
pub enum CacheDisabledReason {
5050
InProcessExecution,
5151
NoCacheMetadata,
@@ -77,7 +77,7 @@ pub enum CacheUpdateStatus {
7777
NotUpdated(CacheNotUpdatedReason),
7878
}
7979

80-
#[derive(Debug)]
80+
#[derive(Debug, Clone)]
8181
#[expect(
8282
clippy::large_enum_variant,
8383
reason = "CacheMiss variant is intentionally large and infrequently cloned"

crates/vite_task/src/session/execute/mod.rs

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,13 @@ impl ExecutionContext<'_> {
6666
/// We compute a topological order and iterate in reverse to get execution order
6767
/// (dependencies before dependents).
6868
///
69-
/// `all_ancestors_single_node` tracks whether every graph in the ancestry chain
70-
/// (from the root down to this level) contains exactly one node. The initial call
71-
/// passes `graph.node_count() == 1`; recursive calls AND with the nested graph's
72-
/// node count.
73-
///
7469
/// Fast-fail: if any task fails (non-zero exit or infrastructure error), remaining
7570
/// tasks and `&&`-chained items are skipped. Leaf-level errors are reported through
7671
/// the reporter. Cycle detection is handled at plan time.
7772
///
7873
/// Returns `true` if all tasks succeeded, `false` if any task failed.
7974
#[tracing::instrument(level = "debug", skip_all)]
80-
async fn execute_expanded_graph(
81-
&mut self,
82-
graph: &ExecutionGraph,
83-
all_ancestors_single_node: bool,
84-
) -> bool {
75+
async fn execute_expanded_graph(&mut self, graph: &ExecutionGraph) -> bool {
8576
// `compute_topological_order()` returns nodes in topological order: for every
8677
// edge A→B, A appears before B. Since our edges mean "A depends on B",
8778
// dependencies (B) appear after their dependents (A). We iterate in reverse
@@ -97,23 +88,13 @@ impl ExecutionContext<'_> {
9788
for item in &task_execution.items {
9889
let failed = match &item.kind {
9990
ExecutionItemKind::Leaf(leaf_kind) => {
100-
self.execute_leaf(
101-
&item.execution_item_display,
102-
leaf_kind,
103-
all_ancestors_single_node,
104-
)
105-
.boxed_local()
106-
.await
107-
}
108-
ExecutionItemKind::Expanded(nested_graph) => {
109-
!self
110-
.execute_expanded_graph(
111-
nested_graph,
112-
all_ancestors_single_node && nested_graph.node_count() == 1,
113-
)
91+
self.execute_leaf(&item.execution_item_display, leaf_kind)
11492
.boxed_local()
11593
.await
11694
}
95+
ExecutionItemKind::Expanded(nested_graph) => {
96+
!self.execute_expanded_graph(nested_graph).boxed_local().await
97+
}
11798
};
11899
if failed {
119100
return false;
@@ -134,10 +115,8 @@ impl ExecutionContext<'_> {
134115
&mut self,
135116
display: &ExecutionItemDisplay,
136117
leaf_kind: &LeafExecutionKind,
137-
all_ancestors_single_node: bool,
138118
) -> bool {
139-
let mut leaf_reporter =
140-
self.reporter.new_leaf_execution(display, leaf_kind, all_ancestors_single_node);
119+
let mut leaf_reporter = self.reporter.new_leaf_execution(display, leaf_kind);
141120

142121
match leaf_kind {
143122
LeafExecutionKind::InProcess(in_process_execution) => {
@@ -541,8 +520,7 @@ impl Session<'_> {
541520

542521
// Execute the graph with fast-fail: if any task fails, remaining tasks
543522
// are skipped. Leaf-level errors are reported through the reporter.
544-
let all_single_node = execution_graph.node_count() == 1;
545-
execution_context.execute_expanded_graph(&execution_graph, all_single_node).await;
523+
execution_context.execute_expanded_graph(&execution_graph).await;
546524

547525
// Leaf-level errors and non-zero exit statuses are tracked internally
548526
// by the reporter.

crates/vite_task/src/session/mod.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use clap::Parser as _;
1212
use once_cell::sync::OnceCell;
1313
pub use reporter::ExitStatus;
1414
use reporter::{
15-
LabeledReporterBuilder,
15+
GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
16+
SummaryReporterBuilder,
1617
summary::{LastRunSummary, ReadSummaryError, format_full_summary},
1718
};
1819
use rustc_hash::FxHashMap;
@@ -300,14 +301,33 @@ impl<'a> Session<'a> {
300301
self.plan_from_query(qpr).await?
301302
};
302303

303-
let builder = LabeledReporterBuilder::new(
304-
self.workspace_path(),
304+
let workspace_path = self.workspace_path();
305+
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());
306+
307+
let inner: Box<dyn reporter::GraphExecutionReporterBuilder> =
308+
match run_command.flags.log {
309+
crate::cli::LogMode::Interleaved => Box::new(
310+
InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer),
311+
),
312+
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
313+
Arc::clone(&workspace_path),
314+
writer,
315+
)),
316+
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
317+
Arc::clone(&workspace_path),
318+
writer,
319+
)),
320+
};
321+
322+
let builder = Box::new(SummaryReporterBuilder::new(
323+
inner,
324+
workspace_path,
305325
Box::new(std::io::stdout()),
306326
run_command.flags.verbose,
307327
Some(self.make_summary_writer()),
308328
self.program_name.clone(),
309-
);
310-
self.execute_graph(graph, Box::new(builder)).await.map_err(SessionError::EarlyExit)
329+
));
330+
self.execute_graph(graph, builder).await.map_err(SessionError::EarlyExit)
311331
}
312332
}
313333
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//! Grouped reporter — buffers output per task, prints as a block on completion.
2+
3+
use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc};
4+
5+
use vite_path::AbsolutePath;
6+
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};
7+
8+
use super::{
9+
ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter,
10+
StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label,
11+
write_leaf_trailing_output,
12+
};
13+
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};
14+
15+
mod writer;
16+
17+
use writer::GroupedWriter;
18+
19+
pub struct GroupedReporterBuilder {
20+
workspace_path: Arc<AbsolutePath>,
21+
writer: Box<dyn Write>,
22+
}
23+
24+
impl GroupedReporterBuilder {
25+
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
26+
Self { workspace_path, writer }
27+
}
28+
}
29+
30+
impl GraphExecutionReporterBuilder for GroupedReporterBuilder {
31+
fn build(self: Box<Self>) -> Box<dyn GraphExecutionReporter> {
32+
Box::new(GroupedGraphReporter {
33+
writer: Rc::new(RefCell::new(self.writer)),
34+
workspace_path: self.workspace_path,
35+
})
36+
}
37+
}
38+
39+
struct GroupedGraphReporter {
40+
writer: Rc<RefCell<Box<dyn Write>>>,
41+
workspace_path: Arc<AbsolutePath>,
42+
}
43+
44+
impl GraphExecutionReporter for GroupedGraphReporter {
45+
fn new_leaf_execution(
46+
&mut self,
47+
display: &ExecutionItemDisplay,
48+
_leaf_kind: &LeafExecutionKind,
49+
) -> Box<dyn LeafExecutionReporter> {
50+
let label = format_task_label(display);
51+
Box::new(GroupedLeafReporter {
52+
writer: Rc::clone(&self.writer),
53+
display: display.clone(),
54+
workspace_path: Arc::clone(&self.workspace_path),
55+
label,
56+
started: false,
57+
grouped_buffer: None,
58+
})
59+
}
60+
61+
fn finish(self: Box<Self>) -> Result<(), ExitStatus> {
62+
let mut writer = self.writer.borrow_mut();
63+
let _ = writer.flush();
64+
Ok(())
65+
}
66+
}
67+
68+
struct GroupedLeafReporter {
69+
writer: Rc<RefCell<Box<dyn Write>>>,
70+
display: ExecutionItemDisplay,
71+
workspace_path: Arc<AbsolutePath>,
72+
label: vite_str::Str,
73+
started: bool,
74+
grouped_buffer: Option<Rc<RefCell<Vec<u8>>>>,
75+
}
76+
77+
impl LeafExecutionReporter for GroupedLeafReporter {
78+
fn start(&mut self, cache_status: CacheStatus) -> StdioConfig {
79+
let line =
80+
format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status);
81+
82+
self.started = true;
83+
84+
// Print labeled command line immediately (before output is buffered).
85+
let labeled_line = vite_str::format!("{} {line}", self.label);
86+
let mut writer = self.writer.borrow_mut();
87+
let _ = writer.write_all(labeled_line.as_bytes());
88+
let _ = writer.flush();
89+
90+
// Create shared buffer for both stdout and stderr.
91+
let buffer = Rc::new(RefCell::new(Vec::new()));
92+
self.grouped_buffer = Some(Rc::clone(&buffer));
93+
94+
StdioConfig {
95+
suggestion: StdioSuggestion::Piped,
96+
stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))),
97+
stderr_writer: Box::new(GroupedWriter::new(buffer)),
98+
}
99+
}
100+
101+
fn finish(
102+
self: Box<Self>,
103+
_status: Option<StdExitStatus>,
104+
_cache_update_status: CacheUpdateStatus,
105+
error: Option<ExecutionError>,
106+
) {
107+
// Build grouped block: header + buffered output.
108+
let mut extra = Vec::new();
109+
if let Some(ref grouped_buffer) = self.grouped_buffer {
110+
let content = grouped_buffer.borrow();
111+
if !content.is_empty() {
112+
let header = vite_str::format!("── {} ──\n", self.label);
113+
extra.extend_from_slice(header.as_bytes());
114+
extra.extend_from_slice(&content);
115+
}
116+
}
117+
118+
write_leaf_trailing_output(&self.writer, error, self.started, &extra);
119+
}
120+
}
121+
122+
#[cfg(test)]
123+
mod tests {
124+
use vite_task_plan::ExecutionItemKind;
125+
126+
use super::*;
127+
use crate::session::{
128+
event::CacheDisabledReason,
129+
reporter::{
130+
StdioSuggestion,
131+
test_fixtures::{spawn_task, test_path},
132+
},
133+
};
134+
135+
fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind {
136+
match &item.kind {
137+
ExecutionItemKind::Leaf(kind) => kind,
138+
ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"),
139+
}
140+
}
141+
142+
#[test]
143+
fn always_suggests_piped() {
144+
let task = spawn_task("build");
145+
let item = &task.items[0];
146+
147+
let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
148+
let mut reporter = builder.build();
149+
let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item));
150+
let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata));
151+
assert_eq!(stdio_config.suggestion, StdioSuggestion::Piped);
152+
}
153+
}

0 commit comments

Comments
 (0)