Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
mode and time limit are settings in the database (jobs.settings). Existing
files are adopted on start-up (frontmatter into the database and removed from
the file); plain Markdown files registered later get default settings and stay
switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
a template) create .codecraft/jobs/<name>.md and open the editor: settings with
a graphical cron builder (next runs in server time via /jobs/schedule), the
instructions in the Markdown editor and an assistant chat
(POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.
Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
drop, or the group's menu), stored per user via /account/sidebar. A new
message moves the chat to the top of its group, no longer the project itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
30 KiB
Rust
684 lines
30 KiB
Rust
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
|
||
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
|
||
//! limits are settings of the automation in the database, edited in the UI.
|
||
//!
|
||
//! Older job files carried the settings as frontmatter:
|
||
//!
|
||
//! ```markdown
|
||
//! ---
|
||
//! name: Nightly dependency check
|
||
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
|
||
//! agent: build
|
||
//! permission_mode: auto
|
||
//! timeout_minutes: 60
|
||
//! ---
|
||
//! Check for outdated dependencies and open a summary …
|
||
//! ```
|
||
//!
|
||
//! They are adopted once (settings into the database, the frontmatter removed from the
|
||
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
|
||
//! a normal chat session owned by the job's owner.
|
||
|
||
use crate::app::App;
|
||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||
use crate::types::{Part, Session, new_id, now_ms};
|
||
use anyhow::{Context, Result, anyhow, bail};
|
||
use async_trait::async_trait;
|
||
use serde::Serialize;
|
||
use serde_json::{Value, json};
|
||
use std::path::{Path, PathBuf};
|
||
use std::str::FromStr;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Job {
|
||
pub id: String,
|
||
pub owner_id: String,
|
||
pub path: String,
|
||
pub enabled: bool,
|
||
pub last_run: Option<i64>,
|
||
pub last_session_id: Option<String>,
|
||
pub last_status: Option<String>,
|
||
pub created_at: i64,
|
||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||
pub webhook: bool,
|
||
/// None: not adopted yet (settings still in the file's frontmatter).
|
||
#[serde(skip)]
|
||
pub settings: Option<JobSettings>,
|
||
}
|
||
|
||
/// Settings of an automation (database; the file holds only the prompt).
|
||
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
||
pub struct JobSettings {
|
||
pub name: String,
|
||
/// 5-field cron in the server's time zone.
|
||
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,
|
||
/// Working directory (absolute or relative to the file); default: the project of the file.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cwd: Option<String>,
|
||
/// Chat in which the user works out the prompt with a model.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub assistant_session: Option<String>,
|
||
}
|
||
|
||
impl JobSettings {
|
||
pub fn new(name: &str) -> Self {
|
||
Self {
|
||
name: name.trim().to_string(),
|
||
schedule: "0 3 * * *".into(),
|
||
agent: default_agent(),
|
||
model: None,
|
||
permission_mode: default_mode(),
|
||
timeout_minutes: default_timeout(),
|
||
cwd: None,
|
||
assistant_session: None,
|
||
}
|
||
}
|
||
|
||
pub fn validate(&mut self) -> Result<()> {
|
||
self.name = self.name.trim().chars().take(120).collect();
|
||
if self.name.is_empty() {
|
||
bail!("the automation needs a name");
|
||
}
|
||
self.schedule = self.schedule.trim().to_string();
|
||
check_schedule(&self.schedule)?;
|
||
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
|
||
bail!("invalid permission_mode '{}'", self.permission_mode);
|
||
}
|
||
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
|
||
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Valid 5-field cron expression?
|
||
pub fn check_schedule(schedule: &str) -> Result<()> {
|
||
if schedule.split_whitespace().count() != 5 {
|
||
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
|
||
}
|
||
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Parsed job file.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct JobSpec {
|
||
pub name: String,
|
||
pub schedule: String,
|
||
pub agent: String,
|
||
pub model: Option<String>,
|
||
pub permission_mode: String,
|
||
pub timeout_minutes: u64,
|
||
pub cwd: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub assistant_session: Option<String>,
|
||
pub prompt: String,
|
||
}
|
||
|
||
impl JobSpec {
|
||
fn from_settings(s: &JobSettings, prompt: String) -> Self {
|
||
Self {
|
||
name: s.name.clone(),
|
||
schedule: s.schedule.clone(),
|
||
agent: s.agent.clone(),
|
||
model: s.model.clone(),
|
||
permission_mode: s.permission_mode.clone(),
|
||
timeout_minutes: s.timeout_minutes,
|
||
cwd: s.cwd.clone(),
|
||
assistant_session: s.assistant_session.clone(),
|
||
prompt,
|
||
}
|
||
}
|
||
|
||
fn settings(&self) -> JobSettings {
|
||
JobSettings {
|
||
name: self.name.clone(),
|
||
schedule: self.schedule.clone(),
|
||
agent: self.agent.clone(),
|
||
model: self.model.clone(),
|
||
permission_mode: self.permission_mode.clone(),
|
||
timeout_minutes: self.timeout_minutes,
|
||
cwd: self.cwd.clone(),
|
||
assistant_session: self.assistant_session.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Settings and prompt of a job: settings from the database, the prompt from its file
|
||
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
|
||
pub fn spec(job: &Job) -> Result<JobSpec> {
|
||
let path = Path::new(&job.path);
|
||
let Some(settings) = &job.settings else { return parse(path) };
|
||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||
let (_, body) = crate::prompts::frontmatter(&text);
|
||
if body.trim().is_empty() {
|
||
bail!("{}: the instructions are empty", path.display());
|
||
}
|
||
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
|
||
}
|
||
|
||
/// Move the frontmatter of an older job file into the database and remove it from the
|
||
/// file. Files without frontmatter get default settings (named after the file) and are
|
||
/// switched off until the user sets a schedule. Returns the updated job.
|
||
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
|
||
if job.settings.is_some() {
|
||
return Ok(job);
|
||
}
|
||
let path = Path::new(&job.path);
|
||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||
let settings = if meta.is_empty() {
|
||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
|
||
store.job_set_enabled(&job.id, false)?;
|
||
JobSettings::new(&stem)
|
||
} else {
|
||
let spec = parse(path)?;
|
||
// Prompt only from now on.
|
||
std::fs::write(path, format!("{}\n", body.trim()))?;
|
||
spec.settings()
|
||
};
|
||
store.job_set_settings(&job.id, &settings)?;
|
||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||
}
|
||
|
||
/// Settings from the frontmatter of an older job file.
|
||
pub fn parse(path: &Path) -> Result<JobSpec> {
|
||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||
// Values may carry a trailing comment (`auto # default`).
|
||
let unquote = |s: &String| {
|
||
let v = s.trim();
|
||
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
|
||
v.trim_matches('"').trim_matches('\'').to_string()
|
||
};
|
||
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
|
||
check_schedule(&schedule)?;
|
||
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
||
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
||
bail!("invalid permission_mode '{permission_mode}'");
|
||
}
|
||
if body.trim().is_empty() {
|
||
bail!("{}: the job has no prompt (text after the frontmatter)", path.display());
|
||
}
|
||
Ok(JobSpec {
|
||
name: meta.get("name").map(unquote).unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("job").to_string()),
|
||
schedule,
|
||
agent: meta.get("agent").map(unquote).unwrap_or_else(|| "build".into()),
|
||
model: meta.get("model").map(unquote).filter(|m| !m.is_empty()),
|
||
permission_mode,
|
||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
||
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||
assistant_session: None,
|
||
prompt: body.trim().to_string(),
|
||
})
|
||
}
|
||
|
||
/// Next run after `after` (ms) in the server's local time zone.
|
||
pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
|
||
let cron = croner::Cron::from_str(schedule).ok()?;
|
||
let start = chrono::DateTime::from_timestamp_millis(after_ms)?.with_timezone(&chrono::Local);
|
||
cron.find_next_occurrence(&start, false).ok().map(|t| t.timestamp_millis())
|
||
}
|
||
|
||
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
||
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
|
||
}
|
||
|
||
/// Working directory for a job file and its optional `cwd` setting.
|
||
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
|
||
let path = Path::new(file);
|
||
if let Some(c) = cwd {
|
||
let p = PathBuf::from(c);
|
||
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
||
}
|
||
let mut dir = path.parent();
|
||
while let Some(d) = dir {
|
||
if d.file_name().and_then(|n| n.to_str()) == Some(".codecraft") {
|
||
return d.parent().unwrap_or(d).to_path_buf();
|
||
}
|
||
dir = d.parent();
|
||
}
|
||
path.parent().unwrap_or(Path::new("/")).to_path_buf()
|
||
}
|
||
|
||
/// Job with its parsed file and next run, for the UI and the tool.
|
||
pub fn describe(job: &Job) -> Value {
|
||
let mut v = json!(job);
|
||
match spec(job) {
|
||
Ok(spec) => {
|
||
v["spec"] = json!(spec);
|
||
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
|
||
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
|
||
}
|
||
Err(e) => {
|
||
v["error"] = json!(format!("{e:#}"));
|
||
// The settings stay editable when the file is missing or empty.
|
||
if let Some(st) = &job.settings {
|
||
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Start a run now. Returns the new session id.
|
||
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||
run_with(app, job, trigger, None).await
|
||
}
|
||
|
||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||
let path = PathBuf::from(&job.path);
|
||
let spec = match spec(job) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
||
return Err(e);
|
||
}
|
||
};
|
||
let owner = app.store.user(&job.owner_id)?.ok_or_else(|| anyhow!("the job's owner no longer exists"))?;
|
||
if owner.disabled {
|
||
bail!("the job's owner is disabled");
|
||
}
|
||
let cwd = job_cwd(&path, &spec);
|
||
let roots = app.user_roots(Some(&owner.id));
|
||
if !roots.iter().any(|r| cwd.starts_with(r)) {
|
||
bail!("{} is outside the owner's folders", cwd.display());
|
||
}
|
||
let cfg = app.config();
|
||
let model = spec.model.clone().or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?;
|
||
let now = now_ms();
|
||
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M");
|
||
let session = Session {
|
||
id: new_id(),
|
||
title: format!("{} – {stamp}", spec.name),
|
||
cwd: cwd.display().to_string(),
|
||
model,
|
||
agent: spec.agent.clone(),
|
||
permission_mode: spec.permission_mode.clone(),
|
||
parent_id: None,
|
||
owner_id: Some(owner.id.clone()),
|
||
created_at: now,
|
||
updated_at: now,
|
||
todos: Value::Null,
|
||
last_usage: None,
|
||
clean_room: None,
|
||
phase: None,
|
||
worktree: None,
|
||
pinned: false,
|
||
archived: false,
|
||
folder: None,
|
||
instructions: String::new(),
|
||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||
telegram_quiet: true,
|
||
};
|
||
let session = app.save_session(session)?;
|
||
app.store.job_started(&job.id, &session.id, now)?;
|
||
let text = format!(
|
||
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
|
||
spec.name, job.path, spec.prompt
|
||
);
|
||
let text = match payload {
|
||
Some(p) => format!("{text}\n\n<webhook-payload>\n{p}\n</webhook-payload>\nThe automation was triggered by this webhook call – use the payload as input."),
|
||
None => text,
|
||
};
|
||
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
|
||
|
||
// Watch for the end (or the timeout).
|
||
let app2 = app.clone();
|
||
let job_id = job.id.clone();
|
||
let sid = session.id.clone();
|
||
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
|
||
tokio::spawn(async move {
|
||
let started = std::time::Instant::now();
|
||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||
while app2.is_busy(&sid) {
|
||
if started.elapsed() > timeout {
|
||
crate::runner::abort(&app2, &sid);
|
||
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
|
||
return;
|
||
}
|
||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||
}
|
||
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
|
||
});
|
||
Ok(session.id)
|
||
}
|
||
|
||
/// Background scheduler: checks every 30 s which jobs are due.
|
||
pub fn spawn_scheduler(app: Arc<App>) {
|
||
tokio::spawn(async move {
|
||
// Older job files: settings from the frontmatter into the database.
|
||
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
|
||
let path = job.path.clone();
|
||
match adopt(&app.store, job) {
|
||
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
|
||
Err(e) => tracing::warn!("automation {path}: {e:#}"),
|
||
}
|
||
}
|
||
loop {
|
||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||
let Ok(jobs) = app.store.jobs(None) else { continue };
|
||
let now = now_ms();
|
||
for job in jobs.into_iter().filter(|j| j.enabled) {
|
||
let Ok(spec) = spec(&job) else { continue };
|
||
// Due if a scheduled time passed since the last run (or since creation).
|
||
let base = job.last_run.unwrap_or(job.created_at);
|
||
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
||
if next > now {
|
||
continue;
|
||
}
|
||
// Missed runs while the server was down: run once, not for every slot.
|
||
if job.last_status.as_deref() == Some("running") && job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
|
||
continue;
|
||
}
|
||
if let Err(e) = run(&app, &job, "schedule").await {
|
||
tracing::warn!("job {} ({}): {e:#}", job.id, job.path);
|
||
let _ = app.store.job_finished(&job.id, None, &format!("error: {e:#}"));
|
||
// Avoid retrying every 30 s.
|
||
let _ = app.store.job_touch(&job.id, now);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- tool -------------------------------------------------------------------------
|
||
|
||
/// Tool: create, list, run and delete automations.
|
||
pub struct Schedule;
|
||
|
||
fn default_agent() -> String {
|
||
"build".into()
|
||
}
|
||
fn default_mode() -> String {
|
||
"auto".into()
|
||
}
|
||
fn default_timeout() -> u64 {
|
||
60
|
||
}
|
||
|
||
/// Write the instructions file, register it and store the settings.
|
||
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
|
||
settings.validate()?;
|
||
if path.exists() {
|
||
bail!("{} already exists", path.display());
|
||
}
|
||
if let Some(dir) = path.parent() {
|
||
std::fs::create_dir_all(dir)?;
|
||
}
|
||
std::fs::write(path, format!("{}\n", prompt.trim()))?;
|
||
let job = store.job_register(owner, &path.display().to_string())?;
|
||
store.job_set_settings(&job.id, &settings)?;
|
||
store.job_set_enabled(&job.id, enabled)?;
|
||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||
}
|
||
|
||
/// 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 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()
|
||
}
|
||
|
||
#[async_trait]
|
||
impl Tool for Schedule {
|
||
fn name(&self) -> &str {
|
||
"schedule"
|
||
}
|
||
fn description(&self) -> String {
|
||
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
|
||
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
|
||
limits are settings of the automation. Actions: \
|
||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
|
||
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
|
||
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
||
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
||
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
||
.into()
|
||
}
|
||
fn schema(&self) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {"type": "string", "enum": ["create", "register", "list", "run", "enable", "disable", "delete"]},
|
||
"name": {"type": "string"},
|
||
"schedule": {"type": "string", "description": "cron, e.g. \"0 3 * * *\""},
|
||
"prompt": {"type": "string", "description": "Complete instructions for the scheduled agent"},
|
||
"agent": {"type": "string", "description": "Mode, default build"},
|
||
"model": {"type": "string"},
|
||
"permission_mode": {"type": "string", "enum": crate::permission::MODES},
|
||
"timeout_minutes": {"type": "integer"},
|
||
"path": {"type": "string"},
|
||
"id": {"type": "string"},
|
||
"delete_file": {"type": "boolean"}
|
||
},
|
||
"required": ["action"]
|
||
})
|
||
}
|
||
fn read_only(&self) -> bool {
|
||
false
|
||
}
|
||
fn title(&self, input: &Value) -> String {
|
||
format!("Automation: {} {}", input["action"].as_str().unwrap_or(""), input["name"].as_str().or(input["id"].as_str()).unwrap_or(""))
|
||
}
|
||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).ok_or_else(|| anyhow!("session has no owner"))?;
|
||
let own_job = |id: &str| -> Result<Job> {
|
||
ctx.app.store.job(id)?.filter(|j| j.owner_id == owner).ok_or_else(|| anyhow!("no automation with id {id}"))
|
||
};
|
||
match arg_str(&input, "action")? {
|
||
"create" => {
|
||
let name = arg_str(&input, "name")?;
|
||
let schedule = arg_str(&input, "schedule")?;
|
||
let prompt = arg_str(&input, "prompt")?;
|
||
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
||
let path = ctx.resolve(&rel)?;
|
||
let settings = JobSettings {
|
||
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),
|
||
..JobSettings::new(name)
|
||
};
|
||
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
|
||
let d = describe(&job);
|
||
Ok(ToolOutput::text(format!(
|
||
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
||
rel,
|
||
job.id,
|
||
fmt_time(d["next_run"].as_i64())
|
||
))
|
||
.with_meta(d))
|
||
}
|
||
"register" => {
|
||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||
if !path.is_file() {
|
||
bail!("{} is not a file", path.display());
|
||
}
|
||
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
|
||
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
||
}
|
||
"list" => {
|
||
let jobs = ctx.app.store.jobs(Some(&owner))?;
|
||
if jobs.is_empty() {
|
||
return Ok(ToolOutput::text("No automations."));
|
||
}
|
||
let mut out = String::new();
|
||
for j in &jobs {
|
||
let d = describe(j);
|
||
out.push_str(&format!(
|
||
"- {} [{}] {} – schedule {} – next {} – last {} ({})\n",
|
||
j.id,
|
||
if j.enabled { "on" } else { "off" },
|
||
d["spec"]["name"].as_str().unwrap_or("?"),
|
||
d["spec"]["schedule"].as_str().unwrap_or("?"),
|
||
fmt_time(d["next_run"].as_i64()),
|
||
fmt_time(j.last_run),
|
||
j.last_status.as_deref().unwrap_or("-"),
|
||
));
|
||
}
|
||
Ok(ToolOutput::text(out))
|
||
}
|
||
"run" => {
|
||
let job = own_job(arg_str(&input, "id")?)?;
|
||
let sid = run(&ctx.app, &job, "manual").await?;
|
||
Ok(ToolOutput::text(format!("Started run in session {sid}.")))
|
||
}
|
||
a @ ("enable" | "disable") => {
|
||
let job = own_job(arg_str(&input, "id")?)?;
|
||
ctx.app.store.job_set_enabled(&job.id, a == "enable")?;
|
||
Ok(ToolOutput::text(format!("Automation {} {a}d.", job.id)))
|
||
}
|
||
"delete" => {
|
||
let job = own_job(arg_str(&input, "id")?)?;
|
||
ctx.app.store.job_delete(&job.id)?;
|
||
if input["delete_file"].as_bool().unwrap_or(false) {
|
||
let _ = std::fs::remove_file(&job.path);
|
||
}
|
||
Ok(ToolOutput::text(format!("Automation {} deleted.", job.id)))
|
||
}
|
||
other => bail!("unknown action '{other}'"),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fmt_time(ms: Option<i64>) -> String {
|
||
ms.and_then(chrono::DateTime::from_timestamp_millis).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M %Z").to_string()).unwrap_or_else(|| "-".into())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn next_run_works() {
|
||
let base = chrono::DateTime::parse_from_rfc3339("2026-11-08T10:15:00+00:00").unwrap().timestamp_millis();
|
||
let n = next_run("*/30 * * * *", base).unwrap();
|
||
assert_eq!(n - base, 15 * 60 * 1000);
|
||
assert!(next_run("not a cron", base).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn parses_job_file() {
|
||
let dir = std::env::temp_dir().join(format!("cc-job-{}", std::process::id()));
|
||
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||
let p = dir.join(".codecraft/jobs/x.md");
|
||
std::fs::write(&p, "---\nname: \"Nightly\"\nschedule: \"0 3 * * *\"\n---\nDo things.\n").unwrap();
|
||
let s = parse(&p).unwrap();
|
||
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
|
||
assert_eq!(job_cwd(&p, &s), dir);
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn adopts_frontmatter_and_creates_prompt_only_files() {
|
||
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
|
||
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||
// Older file: settings move into the database, the file keeps only the prompt.
|
||
let p = dir.join(".codecraft/jobs/old.md");
|
||
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
|
||
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
|
||
let st = job.settings.clone().unwrap();
|
||
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
|
||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
|
||
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
|
||
assert!(job.enabled);
|
||
// Plain Markdown: default settings, switched off.
|
||
let q = dir.join(".codecraft/jobs/weekly-report.md");
|
||
std::fs::write(&q, "Write a report.\n").unwrap();
|
||
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
|
||
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
|
||
assert!(!job.enabled);
|
||
// New automation.
|
||
let r = dir.join(".codecraft/jobs/new.md");
|
||
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
|
||
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
|
||
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
|
||
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
}
|
||
|
||
// --- incoming webhooks -------------------------------------------------------------
|
||
|
||
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
|
||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||
}
|
||
|
||
fn hmac_hex(secret: &str, body: &[u8]) -> String {
|
||
use hmac::{KeyInit, Mac};
|
||
let mut mac = <hmac::Hmac<sha2::Sha256> as KeyInit>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||
mac.update(body);
|
||
mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect()
|
||
}
|
||
|
||
/// Check a webhook call: shared secret (header/query) or Gitea/GitHub HMAC signature.
|
||
pub fn verify_webhook(secret: &str, headers: &axum::http::HeaderMap, query_secret: Option<&str>, body: &[u8]) -> bool {
|
||
let h = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(|v| v.trim().to_string());
|
||
if let Some(given) = h("x-codecraft-secret").or(query_secret.map(String::from)) {
|
||
return constant_eq(given.as_bytes(), secret.as_bytes());
|
||
}
|
||
let expected = hmac_hex(secret, body);
|
||
if let Some(sig) = h("x-gitea-signature").or(h("x-gogs-signature")) {
|
||
return constant_eq(sig.to_lowercase().as_bytes(), expected.as_bytes());
|
||
}
|
||
if let Some(sig) = h("x-hub-signature-256") {
|
||
return constant_eq(sig.trim_start_matches("sha256=").to_lowercase().as_bytes(), expected.as_bytes());
|
||
}
|
||
false
|
||
}
|
||
|
||
/// The payload as text for the prompt (pretty JSON, event name, size-limited).
|
||
pub fn payload_text(headers: &axum::http::HeaderMap, body: &[u8]) -> String {
|
||
let event = ["x-gitea-event", "x-github-event", "x-gitlab-event", "x-event-key"]
|
||
.iter()
|
||
.find_map(|k| headers.get(*k).and_then(|v| v.to_str().ok()))
|
||
.map(|e| format!("event: {e}\n"))
|
||
.unwrap_or_default();
|
||
let text = match serde_json::from_slice::<Value>(body) {
|
||
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
|
||
Err(_) => String::from_utf8_lossy(body).into_owned(),
|
||
};
|
||
let mut text: String = text.chars().take(20_000).collect();
|
||
if body.len() > 20_000 {
|
||
text.push_str("\n… (payload truncated)");
|
||
}
|
||
format!("{event}{text}")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod webhook_tests {
|
||
#[test]
|
||
fn signatures() {
|
||
let mut h = axum::http::HeaderMap::new();
|
||
let sig = super::hmac_hex("s3cret", b"{\"a\":1}");
|
||
h.insert("x-hub-signature-256", format!("sha256={sig}").parse().unwrap());
|
||
assert!(super::verify_webhook("s3cret", &h, None, b"{\"a\":1}"));
|
||
assert!(!super::verify_webhook("other", &h, None, b"{\"a\":1}"));
|
||
let mut h2 = axum::http::HeaderMap::new();
|
||
h2.insert("x-gitea-signature", sig.parse().unwrap());
|
||
assert!(super::verify_webhook("s3cret", &h2, None, b"{\"a\":1}"));
|
||
assert!(super::verify_webhook("abc", &axum::http::HeaderMap::new(), Some("abc"), b""));
|
||
assert!(!super::verify_webhook("abc", &axum::http::HeaderMap::new(), None, b""));
|
||
}
|
||
}
|