26809df720
- Add workshop-schedule crate (petgraph-based DAG scheduling) - Implement all bake decorators with combinator chain execution - Refactor notify module: extract handler, rule, template, util, queue - Add NotificationQueue with priority/drain semantics - Enhance finalize plugin system with internal plugin registration - Update ExecutionContext and event types - Add per-crate AGENTS.md knowledge base files - Remove inline AGENTS.md files (consolidated into per-crate docs)
229 lines
8.6 KiB
Rust
229 lines
8.6 KiB
Rust
use crate::bake::builder::RenderMode;
|
|
use crate::bake::constant::DEFAULT_WORKSPACE;
|
|
use crate::bake::decorator::Decorator;
|
|
use crate::bake::error::BakeError;
|
|
use crate::bake::util::{resolve_mode, FuncMode};
|
|
use crate::cli::Cli;
|
|
use crate::prebake;
|
|
use crate::prebake::PrebakeConfig;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
use workshop_baker_params::BakeSettings;
|
|
use workshop_engine::{Engine, EventSender, ExecutionContext};
|
|
|
|
mod builder;
|
|
pub mod constant;
|
|
mod decorator;
|
|
pub mod error;
|
|
mod execute;
|
|
pub mod parser;
|
|
mod schedule;
|
|
pub mod trivial;
|
|
pub mod util;
|
|
|
|
pub async fn bake(
|
|
script_path: &Path,
|
|
bake_base_path: &Path,
|
|
prebake_path: &Path,
|
|
_cli: &Cli,
|
|
ctx: &mut ExecutionContext,
|
|
event_tx: EventSender,
|
|
) -> Result<(), BakeError> {
|
|
log::debug!("Reading script: {:?}", script_path);
|
|
let script_content = std::fs::read_to_string(script_path).map_err(|e| {
|
|
BakeError::IoError { path: script_path.display().to_string(), source: e }
|
|
})?;
|
|
log::debug!("Reading prebake.yml: {:?}", prebake_path);
|
|
let prebake_content = std::fs::read_to_string(prebake_path).map_err(|e| {
|
|
BakeError::IoError { path: prebake_path.display().to_string(), source: e }
|
|
})?;
|
|
let prebake = prebake::parse(prebake_content.as_str()).map_err(|e| {
|
|
log::error!("Failed to parse prebake.yml: {}", e);
|
|
BakeError::YamlParseError(e)
|
|
})?;
|
|
let workspace = get_workspace(&prebake);
|
|
let functions = parser::parse_script(&script_content)?;
|
|
|
|
let has_pipeline = functions.pipeline_functions.iter().any(|f| {
|
|
f.decorators
|
|
.iter()
|
|
.any(|d| matches!(d, decorator::Decorator::Pipeline))
|
|
});
|
|
// TODO: if PIP(pipeline implicit parameter).trivial is set, always use RenderMode::Off
|
|
let render_mode = RenderMode::Full;
|
|
|
|
let engine = Engine::new();
|
|
// TODO: resolve from merged param layers (PIP integration)
|
|
let bake_settings = BakeSettings::default();
|
|
if let Some(env) = &prebake.envvars
|
|
&& let Some(prebake_env) = &env.bake
|
|
{
|
|
ctx.env_vars.extend(prebake_env.clone());
|
|
}
|
|
|
|
if !has_pipeline {
|
|
log::trace!("Running trivial script");
|
|
trivial::run_trivial(
|
|
&script_content,
|
|
bake_base_path,
|
|
&workspace,
|
|
&engine,
|
|
ctx,
|
|
&event_tx,
|
|
render_mode,
|
|
)
|
|
.await?;
|
|
} else {
|
|
log::trace!("Running pipeline script");
|
|
let remaining = functions.remaining_code;
|
|
let (mut dag, func_map) = schedule::build_dag(functions.pipeline_functions)?;
|
|
|
|
// Pre-scan: pipe sources + decorator conflict checks.
|
|
let mut pipe_sources: HashSet<String> = HashSet::new();
|
|
for func in func_map.values() {
|
|
decorator::check_conflicts(func)?;
|
|
for decorator in &func.decorators {
|
|
if let Decorator::Pipe(names) = decorator {
|
|
for source in names {
|
|
if *source != func.name {
|
|
pipe_sources.insert(source.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut stdout_cache: HashMap<String, String> = HashMap::new();
|
|
let mut async_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
|
|
|
|
while !dag.is_empty() {
|
|
log::debug!("[BAKE_SCHED] ready={} seq={} wild={} groups={:?}",
|
|
dag.ready_count(),
|
|
dag.ready_sequential().len(),
|
|
dag.ready_wildcards().len(),
|
|
dag.ready_groups(),
|
|
);
|
|
// Rule 1: sequential always goes first
|
|
let seq_candidates = dag.ready_sequential();
|
|
if let Some(seq_name) = seq_candidates.first() {
|
|
let seq_name = seq_name.to_string();
|
|
log::info!("[BAKE_SCHED] seq {}", seq_name);
|
|
let func = &func_map[&seq_name];
|
|
let mode = resolve_mode(func);
|
|
|
|
match mode {
|
|
FuncMode::Daemon => {
|
|
execute::execute_daemon(
|
|
&engine, func, ctx, &event_tx,
|
|
&workspace, &remaining, bake_base_path,
|
|
Duration::from_secs(bake_settings.health_period),
|
|
Duration::from_secs(bake_settings.health_timeout),
|
|
bake_settings.health_max_retries,
|
|
).await?;
|
|
}
|
|
FuncMode::Async => {
|
|
let handle = execute::execute_async(
|
|
func, ctx, &event_tx,
|
|
&workspace, &remaining, bake_base_path,
|
|
);
|
|
async_handles.push(handle);
|
|
}
|
|
FuncMode::Normal => {
|
|
let result = execute::execute_normal(
|
|
&engine, func, ctx, &event_tx,
|
|
&workspace, &remaining, bake_base_path, &stdout_cache,
|
|
).await;
|
|
match result {
|
|
Ok(stdout) => {
|
|
if pipe_sources.contains(&seq_name) {
|
|
stdout_cache.insert(seq_name.clone(), stdout);
|
|
}
|
|
}
|
|
Err(e) if dag.is_fallible(&seq_name) => {
|
|
log::warn!("'{}' failed (fallible): {}", seq_name, e);
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
}
|
|
// Pop AFTER execution (Daemon/Async pop at start)
|
|
if !matches!(mode, FuncMode::Daemon | FuncMode::Async) {
|
|
dag.pop(&seq_name).map_err(|e| BakeError::IncompatibleDecorators {
|
|
function: seq_name.clone(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
} else {
|
|
dag.pop(&seq_name).map_err(|e| BakeError::IncompatibleDecorators {
|
|
function: seq_name.clone(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// All remaining are parallel — pick a batch
|
|
if let Some(batch) = dag.pick_batch() {
|
|
log::info!("[BAKE_SCHED] par {:?}", batch);
|
|
let results = execute::fire_parallel_batch(
|
|
&batch, &engine, &func_map, ctx, &event_tx,
|
|
&workspace, &remaining, bake_base_path, &stdout_cache,
|
|
).await?;
|
|
for (name, stdout) in results {
|
|
if pipe_sources.contains(&name) {
|
|
stdout_cache.insert(name.clone(), stdout);
|
|
}
|
|
if let Err(e) = dag.pop(&name) {
|
|
log::warn!("Failed to pop '{}' from DAG: {}", name, e);
|
|
}
|
|
}
|
|
} else if dag.is_stalled() {
|
|
// Deadlock: named parallel groups exist but can't complete
|
|
let stalled = dag.stalled_groups();
|
|
let remaining = dag.remaining();
|
|
return Err(BakeError::IncompatibleDecorators {
|
|
function: remaining.join(", "),
|
|
reason: format!(
|
|
"parallel group(s) {:?} can never complete — blocked by @after chain",
|
|
stalled
|
|
),
|
|
});
|
|
} else {
|
|
// Ready set is empty — cycle
|
|
let remaining = dag.remaining();
|
|
if !remaining.is_empty() {
|
|
return Err(BakeError::CircularDependency(
|
|
remaining.into_iter().map(|s| s.to_string()).collect(),
|
|
));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Wait for all @async tasks to finish
|
|
for handle in async_handles {
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_workspace(prebake_config: &PrebakeConfig) -> PathBuf {
|
|
prebake_config
|
|
.bootstrap
|
|
.as_ref()
|
|
.and_then(|b| b.workspace.as_ref())
|
|
.map(|ws| {
|
|
if ws.fallback {
|
|
PathBuf::from(DEFAULT_WORKSPACE)
|
|
} else {
|
|
PathBuf::from(ws.path.clone())
|
|
}
|
|
})
|
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_WORKSPACE))
|
|
}
|
|
|
|
// ── AI Coding Agent marker ──
|
|
// Code in this module PARTIALLY or FULLY utilized AI Coding Agent
|