Files
codecraft/server/src/jobs.rs
T
Richard ZinkandClaude Opus 5.5 05c215f685 Automation editor with graphical schedule and assistant; movable chat list groups
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>
2026-11-10 10:05:15 +01:00

684 lines
30 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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""));
}
}