Add backups with S3, daily cost push, automation templates and a browser tool

- Daily backups (database + config) into <data_dir>/backups, keep N,
  optional upload to any S3-compatible bucket (SigV4, bucket pruning);
  settings panel with run-now, list and download
- Daily cost summary push at 20:00 (opt-in per user, preview button)
- Automations from templates (dependency check, security review, test
  health, daily summary, TODO report, docs freshness) via POST /jobs/create
- browser tool: headless Chromium over CDP – click, type, keys, waits,
  JavaScript, navigation, screenshots, console errors; requests to private
  network addresses are blocked (sub-resources and redirects included)
- Secrets masked in the config view: S3 secret key, metrics token
- Replace the remaining text arrows/refresh glyphs with SVG icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 06:07:03 +01:00
1 parent 873447ed17
commit 01c1484033
25 files changed
+1720 -47

No files matched your search

+71 -12
View File
@@ -233,8 +233,66 @@ pub fn spawn_scheduler(app: Arc<App>) {
/// Tool: create, list, run and delete automations.
pub struct Schedule;
/// Fields of a job file written by the agent tool or the "from template" dialog.
#[derive(Debug, serde::Deserialize)]
pub struct NewJob {
pub name: String,
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
pub prompt: String,
}
fn default_agent() -> String {
"build".into()
}
fn default_mode() -> String {
"auto".into()
}
fn default_timeout() -> u64 {
60
}
/// Write (and validate) a job file.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {}\n", plain(m)));
}
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
let dir = project.join(".codecraft/jobs");
let base = slug(name);
(1..100).map(|i| dir.join(if i == 1 { format!("{base}.md") } else { format!("{base}-{i}.md") })).find(|p| !p.exists()).unwrap_or_else(|| dir.join(format!("{base}-{}.md", new_id())))
}
fn slug(s: &str) -> String {
let out: String = s.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
let lower = s.to_lowercase().replace('ä', "ae").replace('ö', "oe").replace('ü', "ue").replace('ß', "ss");
let out: String = lower.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect()
}
@@ -288,19 +346,20 @@ impl Tool for Schedule {
let name = arg_str(&input, "name")?;
let schedule = arg_str(&input, "schedule")?;
let prompt = arg_str(&input, "prompt")?;
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'"));
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(name), q(schedule), input["agent"].as_str().unwrap_or("build"));
if let Some(m) = input["model"].as_str().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {m}\n"));
}
fm.push_str(&format!("permission_mode: {}\n", input["permission_mode"].as_str().unwrap_or("auto")));
fm.push_str(&format!("timeout_minutes: {}\n---\n", input["timeout_minutes"].as_u64().unwrap_or(60)));
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, format!("{fm}{}\n", prompt.trim()))?;
parse(&path)?;
write_file(
&path,
&NewJob {
name: name.into(),
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
prompt: prompt.into(),
},
)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let d = describe(&job);
Ok(ToolOutput::text(format!(