use crate::commands::workers::builds_config::{
print_build_command_sync_report, sync_local_wrangler_build_command,
sync_workers_builds_build_command_with_root, BuildCommandSyncReport,
};
use crate::utils::{
classify_builds_failure, detect_js_install_package_manager, ensure_pnpm_workspace_packages,
flatten_build_logs_json, suggested_build_command, BuildsFailureClassification,
};
use colored::Colorize;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use xbp_providers::CloudflareClient;
#[derive(Debug, Clone)]
pub struct BuildsFixReport {
pub script_name: String,
pub classification: BuildsFailureClassification,
pub log_source: String,
pub healed_workspace_files: Vec<PathBuf>,
pub desired_build_command: Option<String>,
pub package_manager: &'static str,
pub sync: Option<BuildCommandSyncReport>,
pub notes: Vec<String>,
pub committed: bool,
}
#[derive(Debug, Clone, Default)]
pub struct BuildsFixOptions {
pub dry_run: bool,
pub no_remote: bool,
pub log_path: Option<PathBuf>,
pub commit: bool,
pub always_heal: bool,
}
pub async fn load_builds_failure_log(
client: Option<&CloudflareClient>,
script_name: &str,
log_path: Option<&Path>,
) -> Result<(String, String), String> {
if let Some(path) = log_path {
let text = fs::read_to_string(path)
.map_err(|e| format!("read log {}: {e}", path.display()))?;
return Ok((text, format!("file:{}", path.display())));
}
let client = client.ok_or_else(|| {
"no Cloudflare client and no --log path — cannot fetch build logs".to_string()
})?;
let scripts = client.list_worker_scripts().await?;
let script = scripts
.iter()
.find(|s| s.id == script_name || s.id.eq_ignore_ascii_case(script_name))
.ok_or_else(|| format!("Worker script `{script_name}` not found"))?;
let tag = script
.tag
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
format!("Worker `{script_name}` has no Builds tag — connect Workers Builds first")
})?;
let builds = client.list_worker_builds(tag).await?;
let failed = builds.iter().find(|b| {
b.status
.as_deref()
.map(|s| {
let s = s.to_ascii_lowercase();
s.contains("fail") || s.contains("error") || s == "canceled" || s == "cancelled"
})
.unwrap_or(false)
});
let Some(build) = failed else {
return Err(
"no failed Workers Builds found (latest builds are green or empty)".into(),
);
};
let logs = client
.get_worker_build_logs(&build.build_uuid)
.await
.map_err(|e| format!("fetch logs for build {}: {e}", build.build_uuid))?;
let text = flatten_build_logs_json(&logs);
if text.trim().is_empty() {
return Ok((
text,
format!("build:{} (empty log)", build.build_uuid),
));
}
Ok((text, format!("build:{}", build.build_uuid)))
}
pub async fn fix_workers_builds_failure(
client: Option<&CloudflareClient>,
script_name: &str,
worker_root: &Path,
root_directory: Option<&str>,
opts: BuildsFixOptions,
) -> Result<BuildsFixReport, String> {
let pm = detect_js_install_package_manager(worker_root);
let mut notes = Vec::new();
let (log_text, log_source) = if opts.always_heal && opts.log_path.is_none() && opts.no_remote {
(
String::new(),
"proactive (no log)".to_string(),
)
} else if opts.always_heal && opts.log_path.is_none() {
match load_builds_failure_log(client, script_name, None).await {
Ok(pair) => pair,
Err(e) => {
notes.push(format!("no failed build log: {e}"));
(String::new(), "proactive (no failed build)".into())
}
}
} else {
load_builds_failure_log(client, script_name, opts.log_path.as_deref()).await?
};
let mut classification = if log_text.is_empty() {
BuildsFailureClassification::default()
} else {
classify_builds_failure(&log_text)
};
if opts.always_heal {
classification.packages_field_missing = true;
classification.package_manager_mismatch = true;
if classification.notes.is_empty() {
classification
.notes
.push("proactive heal: workspace packages + install-aware build_command".into());
}
}
if !classification.is_known() && !opts.always_heal {
return Ok(BuildsFixReport {
script_name: script_name.to_string(),
classification,
log_source,
healed_workspace_files: vec![],
desired_build_command: preferred_desired_command(worker_root),
package_manager: pm.name(),
sync: None,
notes: vec![
"unknown failure pattern — no automatic remediation applied".into(),
"known patterns: packages field missing/empty; bun/npm install + pnpm/yarn build"
.into(),
],
committed: false,
});
}
let mut healed = Vec::new();
if classification.needs_workspace_heal() {
if opts.dry_run {
notes.push("would heal pnpm-workspace.yaml packages field (if empty)".into());
for name in ["pnpm-workspace.yaml", "pnpm-workspace.yml"] {
let mut cur = worker_root.to_path_buf();
loop {
let path = cur.join(name);
if path.is_file() {
notes.push(format!("candidate: {}", path.display()));
}
if !cur.pop() {
break;
}
}
}
} else {
healed = ensure_pnpm_workspace_packages(worker_root)?;
for p in &healed {
notes.push(format!("healed {}", p.display()));
}
if healed.is_empty() {
notes.push("pnpm-workspace packages already valid (or no workspace yaml)".into());
} else {
notes.push(
"commit healed pnpm-workspace.yaml if CI still uses pnpm (push required)"
.into(),
);
}
}
}
if let Ok(Some(msg)) = sync_local_wrangler_build_command(worker_root) {
notes.push(msg);
}
let desired = preferred_desired_command(worker_root);
let mut sync_report = None;
if classification.needs_build_command_sync() && !opts.no_remote {
if let Some(client) = client {
let report = sync_workers_builds_build_command_with_root(
client,
script_name,
worker_root,
root_directory,
opts.dry_run,
)
.await?;
sync_report = Some(report);
} else {
notes.push("skipped remote Builds sync (no client)".into());
}
} else if opts.no_remote {
notes.push("skipped remote Builds sync (--no-remote)".into());
if let Some(d) = &desired {
notes.push(format!("desired build_command would be `{d}`"));
}
}
let mut committed = false;
if opts.commit && !opts.dry_run && !healed.is_empty() {
match git_add_paths(worker_root, &healed) {
Ok(()) => {
committed = true;
notes.push("git add: staged healed workspace files (no commit)".into());
}
Err(e) => notes.push(format!("git add failed: {e}")),
}
}
Ok(BuildsFixReport {
script_name: script_name.to_string(),
classification,
log_source,
healed_workspace_files: healed,
desired_build_command: desired,
package_manager: pm.name(),
sync: sync_report,
notes,
committed,
})
}
fn preferred_desired_command(worker_root: &Path) -> Option<String> {
let package_json = worker_root.join("package.json");
let content = fs::read_to_string(package_json).ok()?;
let value: Value = serde_json::from_str(&content).ok()?;
let scripts = value.get("scripts")?.as_object()?;
let script = if scripts.contains_key("build:worker") {
"build:worker"
} else if scripts.contains_key("build") {
"build"
} else {
return None;
};
Some(suggested_build_command(worker_root, script))
}
fn git_add_paths(cwd: &Path, paths: &[PathBuf]) -> Result<(), String> {
for path in paths {
let status = Command::new("git")
.args(["add", "--"])
.arg(path)
.current_dir(cwd)
.status()
.map_err(|e| format!("git add: {e}"))?;
if !status.success() {
return Err(format!("git add failed for {}", path.display()));
}
}
Ok(())
}
pub fn print_builds_fix_report(report: &BuildsFixReport) {
println!(
"{} worker={} pm={} log={}",
"Workers Builds fix".bright_cyan().bold(),
report.script_name.bright_white(),
report.package_manager.bright_yellow(),
report.log_source.bright_black()
);
for n in &report.classification.notes {
println!(" {} {n}", "cause".yellow());
}
if let Some(cmd) = &report.desired_build_command {
println!(" {} desired=`{cmd}`", "build".bright_white());
}
for p in &report.healed_workspace_files {
println!(" {} {}", "healed".green(), p.display());
}
if let Some(sync) = &report.sync {
print_build_command_sync_report(sync);
}
for note in &report.notes {
println!(" {} {note}", "note".yellow());
}
if report.committed {
println!(" {} staged workspace heals", "git".green());
}
}
pub fn looks_like_fixable_builds_error(text: &str) -> bool {
classify_builds_failure(text).is_known()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_user_error() {
let log = "bun install --frozen-lockfile\npnpm run build\npackages field missing or empty";
assert!(looks_like_fixable_builds_error(log));
}
}