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:
1 parent
873447ed17
commit
01c1484033
25 files changed
+1720
-47
No files matched your search
+71
-12
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user