Skip to content

Commit e15e46b

Browse files
feat: add ability to configure tasks as interactive (#7767)
### Description This adds a new `"interactive"` field to task definitions which allows users to interact with tasks when it is set to true. A few notes on the feature: - All persistent tasks automatically are interactive - Interactive tasks cannot be cached since the interaction can (and probably will) alter the task's execution. This could be done automatically, but I chose to require that the task be explicitly marked as `"cache": false` to reduce perceived magic. - `turbo` will not attempt to run if any tasks in the graph are marked as interactive and the experimental UI isn't being used ### Testing Instructions Snapshot test for various validation errors. Manual testing: no longer able to select tasks and interact with them e.g. `turbo_dev build --filter=docs --force` Closes TURBO-2656
1 parent b3c5e34 commit e15e46b

32 files changed

+187
-65
lines changed

crates/turborepo-lib/src/config.rs

+7
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ pub enum Error {
118118
#[source_code]
119119
text: NamedSource,
120120
},
121+
#[error("Tasks cannot be marked as interactive and cacheable")]
122+
InteractiveNoCacheable {
123+
#[label("marked interactive here")]
124+
span: Option<SourceSpan>,
125+
#[source_code]
126+
text: NamedSource,
127+
},
121128
#[error("Failed to create APIClient: {0}")]
122129
ApiClient(#[source] turborepo_api_client::Error),
123130
#[error("{0} is not UTF8.")]

crates/turborepo-lib/src/engine/mod.rs

+33-4
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ impl Engine<Built> {
181181
&self,
182182
package_graph: &PackageGraph,
183183
concurrency: u32,
184+
experimental_ui: bool,
184185
) -> Result<(), Vec<ValidateError>> {
185186
// TODO(olszewski) once this is hooked up to a real run, we should
186187
// see if using rayon to parallelize would provide a speedup
@@ -277,11 +278,34 @@ impl Engine<Built> {
277278
})
278279
}
279280

281+
validation_errors.extend(self.validate_interactive(experimental_ui));
282+
280283
match validation_errors.is_empty() {
281284
true => Ok(()),
282285
false => Err(validation_errors),
283286
}
284287
}
288+
289+
// Validates that UI is setup if any interactive tasks will be executed
290+
fn validate_interactive(&self, experimental_ui: bool) -> Vec<ValidateError> {
291+
// If experimental_ui is being used, then we don't need check for interactive
292+
// tasks
293+
if experimental_ui {
294+
return Vec::new();
295+
}
296+
self.task_definitions
297+
.iter()
298+
.filter_map(|(task, definition)| {
299+
if definition.interactive {
300+
Some(ValidateError::InteractiveNeedsUI {
301+
task: task.to_string(),
302+
})
303+
} else {
304+
None
305+
}
306+
})
307+
.collect()
308+
}
285309
}
286310

287311
#[derive(Debug, Error, Diagnostic)]
@@ -310,6 +334,11 @@ pub enum ValidateError {
310334
persistent_count: u32,
311335
concurrency: u32,
312336
},
337+
#[error(
338+
"Cannot run interactive task \"{task}\" without experimental UI. Set `\"experimentalUI\": \
339+
true` in `turbo.json` or `TURBO_EXPERIMENTAL_UI=true` as an environment variable"
340+
)]
341+
InteractiveNeedsUI { task: String },
313342
}
314343

315344
impl fmt::Display for TaskNode {
@@ -427,16 +456,16 @@ mod test {
427456
let graph = graph_builder.build().await.unwrap();
428457

429458
// if our limit is less than, it should fail
430-
engine.validate(&graph, 1).expect_err("not enough");
459+
engine.validate(&graph, 1, false).expect_err("not enough");
431460

432461
// if our limit is less than, it should fail
433-
engine.validate(&graph, 2).expect_err("not enough");
462+
engine.validate(&graph, 2, false).expect_err("not enough");
434463

435464
// we have two persistent tasks, and a slot for all other tasks, so this should
436465
// pass
437-
engine.validate(&graph, 3).expect("ok");
466+
engine.validate(&graph, 3, false).expect("ok");
438467

439468
// if our limit is greater, then it should pass
440-
engine.validate(&graph, 4).expect("ok");
469+
engine.validate(&graph, 4, false).expect("ok");
441470
}
442471
}

crates/turborepo-lib/src/run/builder.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,11 @@ impl RunBuilder {
423423

424424
if !self.opts.run_opts.parallel {
425425
engine
426-
.validate(pkg_dep_graph, self.opts.run_opts.concurrency)
426+
.validate(
427+
pkg_dep_graph,
428+
self.opts.run_opts.concurrency,
429+
self.experimental_ui,
430+
)
427431
.map_err(Error::EngineValidation)?;
428432
}
429433

crates/turborepo-lib/src/run/error.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::{
1313

1414
#[derive(Debug, Error, Diagnostic)]
1515
pub enum Error {
16-
#[error("invalid persistent task configuration")]
16+
#[error("invalid task configuration")]
1717
EngineValidation(#[related] Vec<ValidateError>),
1818
#[error(transparent)]
1919
Graph(#[from] graph_visualizer::Error),

crates/turborepo-lib/src/run/summary/task.rs

+4
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ pub struct TaskSummaryTaskDefinition {
105105
env: Vec<String>,
106106
pass_through_env: Option<Vec<String>>,
107107
dot_env: Option<Vec<RelativeUnixPathBuf>>,
108+
interactive: bool,
108109
}
109110

110111
#[derive(Debug, Serialize, Clone)]
@@ -282,6 +283,7 @@ impl From<TaskDefinition> for TaskSummaryTaskDefinition {
282283
mut inputs,
283284
output_mode,
284285
persistent,
286+
interactive,
285287
} = value;
286288

287289
let mut outputs = inclusions;
@@ -313,6 +315,7 @@ impl From<TaskDefinition> for TaskSummaryTaskDefinition {
313315
inputs,
314316
output_mode,
315317
persistent,
318+
interactive,
316319
env,
317320
pass_through_env,
318321
// This should _not_ be sorted.
@@ -372,6 +375,7 @@ mod test {
372375
"inputs": [],
373376
"outputMode": "full",
374377
"persistent": false,
378+
"interactive": false,
375379
"env": [],
376380
"passThroughEnv": null,
377381
"dotEnv": null,

crates/turborepo-lib/src/task_graph/mod.rs

+6
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ pub struct TaskDefinition {
7373
// Persistent indicates whether the Task is expected to exit or not
7474
// Tasks marked Persistent do not exit (e.g. --watch mode or dev servers)
7575
pub persistent: bool,
76+
77+
// Interactive marks that a task can have it's stdin written to.
78+
// Tasks that take stdin input cannot be cached as their outputs may depend on the
79+
// input.
80+
pub interactive: bool,
7681
}
7782

7883
impl Default for TaskDefinition {
@@ -88,6 +93,7 @@ impl Default for TaskDefinition {
8893
output_mode: Default::default(),
8994
persistent: Default::default(),
9095
dot_env: Default::default(),
96+
interactive: Default::default(),
9197
}
9298
}
9399
}

crates/turborepo-lib/src/task_graph/visitor.rs

+20-25
Original file line numberDiff line numberDiff line change
@@ -268,14 +268,14 @@ impl<'a> Visitor<'a> {
268268

269269
let workspace_directory = self.repo_root.resolve(workspace_info.package_path());
270270

271-
let persistent = task_definition.persistent;
271+
let takes_input = task_definition.interactive || task_definition.persistent;
272272
let mut exec_context = factory.exec_context(
273273
info.clone(),
274274
task_hash,
275275
task_cache,
276276
workspace_directory,
277277
execution_env,
278-
persistent,
278+
takes_input,
279279
self.task_access.clone(),
280280
);
281281

@@ -629,7 +629,7 @@ impl<'a> ExecContextFactory<'a> {
629629
task_cache: TaskCache,
630630
workspace_directory: AbsoluteSystemPathBuf,
631631
execution_env: EnvironmentVariableMap,
632-
persistent: bool,
632+
takes_input: bool,
633633
task_access: TaskAccess,
634634
) -> ExecContext {
635635
let task_id_for_display = self.visitor.display_task_id(&task_id);
@@ -655,7 +655,7 @@ impl<'a> ExecContextFactory<'a> {
655655
continue_on_error: self.visitor.run_opts.continue_on_error,
656656
pass_through_args,
657657
errors: self.errors.clone(),
658-
persistent,
658+
takes_input,
659659
task_access,
660660
}
661661
}
@@ -691,7 +691,7 @@ struct ExecContext {
691691
continue_on_error: bool,
692692
pass_through_args: Option<Vec<String>>,
693693
errors: Arc<Mutex<Vec<TaskError>>>,
694-
persistent: bool,
694+
takes_input: bool,
695695
task_access: TaskAccess,
696696
}
697697

@@ -876,25 +876,7 @@ impl ExecContext {
876876
cmd.env(task_access_trace_key, trace_file.to_string());
877877
}
878878

879-
// Many persistent tasks if started hooked up to a pseudoterminal
880-
// will shut down if stdin is closed, so we open it even if we don't pass
881-
// anything to it.
882-
if self.persistent {
883-
cmd.open_stdin();
884-
}
885-
886-
let mut stdout_writer = match self.task_cache.output_writer(if self.experimental_ui {
887-
Either::Left(output_client.stdout())
888-
} else {
889-
Either::Right(prefixed_ui.output_prefixed_writer())
890-
}) {
891-
Ok(w) => w,
892-
Err(e) => {
893-
telemetry.track_error(TrackedErrors::FailedToCaptureOutputs);
894-
error!("failed to capture outputs for \"{}\": {e}", self.task_id);
895-
return ExecOutcome::Internal;
896-
}
897-
};
879+
cmd.open_stdin();
898880

899881
let mut process = match self.manager.spawn(cmd, Duration::from_millis(500)) {
900882
Some(Ok(child)) => child,
@@ -918,14 +900,27 @@ impl ExecContext {
918900
}
919901
};
920902

921-
if self.experimental_ui {
903+
if self.experimental_ui && self.takes_input {
922904
if let TaskOutput::UI(task) = output_client {
923905
if let Some(stdin) = process.stdin() {
924906
task.set_stdin(stdin);
925907
}
926908
}
927909
}
928910

911+
let mut stdout_writer = match self.task_cache.output_writer(if self.experimental_ui {
912+
Either::Left(output_client.stdout())
913+
} else {
914+
Either::Right(prefixed_ui.output_prefixed_writer())
915+
}) {
916+
Ok(w) => w,
917+
Err(e) => {
918+
telemetry.track_error(TrackedErrors::FailedToCaptureOutputs);
919+
error!("failed to capture outputs for \"{}\": {e}", self.task_id);
920+
return ExecOutcome::Internal;
921+
}
922+
};
923+
929924
let exit_status = match process.wait_with_piped_outputs(&mut stdout_writer).await {
930925
Ok(Some(exit_status)) => exit_status,
931926
Err(e) => {

crates/turborepo-lib/src/turbo_json/mod.rs

+28-4
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ pub struct RawTaskDefinition {
181181
outputs: Option<Vec<Spanned<UnescapedString>>>,
182182
#[serde(skip_serializing_if = "Option::is_none")]
183183
output_mode: Option<Spanned<OutputLogsMode>>,
184+
#[serde(skip_serializing_if = "Option::is_none")]
185+
interactive: Option<Spanned<bool>>,
184186
}
185187

186188
macro_rules! set_field {
@@ -197,7 +199,10 @@ impl RawTaskDefinition {
197199
pub fn merge(&mut self, other: RawTaskDefinition) {
198200
set_field!(self, other, outputs);
199201

200-
if other.cache.value.is_some() {
202+
if other.cache.value.is_some()
203+
// If other has range info and we're missing it, carry it over
204+
|| (other.cache.range.is_some() && self.cache.range.is_none())
205+
{
201206
self.cache = other.cache;
202207
}
203208
set_field!(self, other, depends_on);
@@ -207,6 +212,7 @@ impl RawTaskDefinition {
207212
set_field!(self, other, env);
208213
set_field!(self, other, pass_through_env);
209214
set_field!(self, other, dot_env);
215+
set_field!(self, other, interactive);
210216
}
211217
}
212218

@@ -262,7 +268,19 @@ impl TryFrom<RawTaskDefinition> for TaskDefinition {
262268
fn try_from(raw_task: RawTaskDefinition) -> Result<Self, Error> {
263269
let outputs = raw_task.outputs.unwrap_or_default().try_into()?;
264270

265-
let cache = raw_task.cache;
271+
let cache = raw_task.cache.into_inner().unwrap_or(true);
272+
let interactive = raw_task
273+
.interactive
274+
.as_ref()
275+
.map(|value| value.value)
276+
.unwrap_or_default();
277+
278+
if let Some(interactive) = raw_task.interactive {
279+
let (span, text) = interactive.span_and_text("turbo.json");
280+
if cache && interactive.value {
281+
return Err(Error::InteractiveNoCacheable { span, text });
282+
}
283+
}
266284

267285
let mut env_var_dependencies = HashSet::new();
268286
let mut topological_dependencies: Vec<Spanned<TaskName>> = Vec::new();
@@ -350,7 +368,7 @@ impl TryFrom<RawTaskDefinition> for TaskDefinition {
350368

351369
Ok(TaskDefinition {
352370
outputs,
353-
cache: cache.into_inner().unwrap_or(true),
371+
cache,
354372
topological_dependencies,
355373
task_dependencies,
356374
env,
@@ -359,6 +377,7 @@ impl TryFrom<RawTaskDefinition> for TaskDefinition {
359377
dot_env,
360378
output_mode: *raw_task.output_mode.unwrap_or_default(),
361379
persistent: *raw_task.persistent.unwrap_or_default(),
380+
interactive,
362381
})
363382
}
364383
}
@@ -910,7 +929,8 @@ mod tests {
910929
"cache": false,
911930
"inputs": ["package/a/src/**"],
912931
"outputMode": "full",
913-
"persistent": true
932+
"persistent": true,
933+
"interactive": true
914934
}"#,
915935
RawTaskDefinition {
916936
depends_on: Some(Spanned::new(vec![Spanned::<UnescapedString>::new("cli#build".into()).with_range(26..37)]).with_range(25..38)),
@@ -922,6 +942,7 @@ mod tests {
922942
inputs: Some(vec![Spanned::<UnescapedString>::new("package/a/src/**".into()).with_range(241..259)]),
923943
output_mode: Some(Spanned::new(OutputLogsMode::Full).with_range(286..292)),
924944
persistent: Some(Spanned::new(true).with_range(318..322)),
945+
interactive: Some(Spanned::new(true).with_range(349..353)),
925946
},
926947
TaskDefinition {
927948
dot_env: Some(vec![RelativeUnixPathBuf::new("package/a/.env").unwrap()]),
@@ -937,6 +958,7 @@ mod tests {
937958
task_dependencies: vec![Spanned::<TaskName<'_>>::new("cli#build".into()).with_range(26..37)],
938959
topological_dependencies: vec![],
939960
persistent: true,
961+
interactive: true,
940962
}
941963
; "full"
942964
)]
@@ -962,6 +984,7 @@ mod tests {
962984
inputs: Some(vec![Spanned::<UnescapedString>::new("package\\a\\src\\**".into()).with_range(273..294)]),
963985
output_mode: Some(Spanned::new(OutputLogsMode::Full).with_range(325..331)),
964986
persistent: Some(Spanned::new(true).with_range(361..365)),
987+
interactive: None,
965988
},
966989
TaskDefinition {
967990
dot_env: Some(vec![RelativeUnixPathBuf::new("package\\a\\.env").unwrap()]),
@@ -977,6 +1000,7 @@ mod tests {
9771000
task_dependencies: vec![Spanned::<TaskName<'_>>::new("cli#build".into()).with_range(30..41)],
9781001
topological_dependencies: vec![],
9791002
persistent: true,
1003+
interactive: false,
9801004
}
9811005
; "full (windows)"
9821006
)]

0 commit comments

Comments
 (0)