diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3059b36..23b2ce1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,28 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
+## [Unreleased]
+
+### Added
+
+- **Automation editor:** *New automation* asks for name, project and a starting point (blank
+ example structure or a template), creates `.codecraft/jobs/.md` and opens an editor:
+ the instructions in a Markdown editor, schedule and settings next to them, and an assistant
+ chat in which a model works out the instructions with you and edits the file (reloaded after
+ each answer). New automations start switched off.
+- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
+ expression, with the next runs in server time.
+- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
+ top via their menu; the order is kept per user on all devices. A new message moves the chat
+ to the top of its folder or project, no longer the whole project.
+
+### Changed
+
+- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
+ and time limit are settings of the automation. Existing job files are taken over on the first
+ start (settings into the database, the frontmatter removed from the file). Registering a
+ plain Markdown file creates a switched-off automation to be scheduled in the editor.
+
## [v202611-00012] – 2026-11-10
### Added
diff --git a/README.md b/README.md
index ad052b1..367201f 100644
--- a/README.md
+++ b/README.md
@@ -202,11 +202,15 @@ platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codec
-An automation is a Markdown file with a cron schedule in its front matter
-(`.codecraft/jobs/.md`). Each run is a normal chat session, so you can read and continue
-it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
-up, start from a template (dependency check, security review of recent commits, test suite
-health, daily summary, TODO report, documentation freshness) or write the file yourself.
+An automation is an agent that starts on a schedule. Its instructions are a Markdown file
+(`.codecraft/jobs/.md`, only the prompt); name, schedule, mode, model and limits are set
+in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
+monthly or a cron expression). *New automation* creates the file – blank with an example
+structure or from a template (dependency check, security review of recent commits, test suite
+health, daily summary, TODO report, documentation freshness) – and opens an editor with an
+assistant chat in which a model works out the instructions with you and writes the file. You
+can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
+read and continue it.
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
diff --git a/server/src/api.rs b/server/src/api.rs
index 66237f4..035d91c 100644
--- a/server/src/api.rs
+++ b/server/src/api.rs
@@ -137,6 +137,7 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
+ .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
@@ -150,7 +151,9 @@ pub fn router(app: AppState) -> Router {
.route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/create", post(create_job))
- .route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
+ .route("/jobs/schedule", get(schedule_preview))
+ .route("/jobs/{id}", get(get_job).patch(update_job).delete(delete_job))
+ .route("/jobs/{id}/assistant", post(job_assistant))
.route("/jobs/{id}/run", post(run_job))
.route("/jobs/{id}/webhook", post(job_webhook_settings))
.route("/terminals", get(list_terminals).post(open_terminal))
@@ -1621,15 +1624,24 @@ async fn list_jobs(Extension(user): Extension, State(app): State
Ok(Json(json!(jobs)))
}
+async fn get_job(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult {
+ let job = own_job(&app, &user, &id)?;
+ Ok(Json(crate::jobs::describe(&job)))
+}
+
#[derive(Deserialize)]
struct RegisterJob {
path: String,
}
+/// Register an existing Markdown file (older files: settings from their frontmatter).
async fn register_job(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult {
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
- crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
+ if !p.is_file() {
+ return Err(ApiError(StatusCode::BAD_REQUEST, format!("{} is not a file", p.display())));
+ }
let job = app.store.job_register(&user.id, &p.display().to_string())?;
+ let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.register", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
@@ -1638,22 +1650,38 @@ async fn register_job(Extension(user): Extension, State(app): State/.codecraft/jobs/.md.
cwd: String,
- #[serde(flatten)]
- job: crate::jobs::NewJob,
+ name: String,
+ /// Initial content of the instructions file (example or template).
+ prompt: String,
+ #[serde(default)]
+ schedule: Option,
+ #[serde(default)]
+ agent: Option,
+ #[serde(default)]
+ permission_mode: Option,
+ /// Default false: the user switches it on once prompt and schedule are ready.
+ #[serde(default)]
+ enabled: bool,
}
async fn create_job(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult {
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
- if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
- return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
+ if b.prompt.trim().is_empty() {
+ return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
}
- if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
- return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
+ let mut settings = crate::jobs::JobSettings::new(&b.name);
+ if let Some(x) = b.schedule {
+ settings.schedule = x;
+ }
+ if let Some(x) = b.agent {
+ settings.agent = x;
+ }
+ if let Some(x) = b.permission_mode {
+ settings.permission_mode = x;
}
// Inside the checked folder: the file name is a slug of the name.
- let path = crate::jobs::new_job_path(&dir, &b.job.name);
- crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
- let job = app.store.job_register(&user.id, &path.display().to_string())?;
+ let path = crate::jobs::new_job_path(&dir, &b.name);
+ let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.create", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
@@ -1661,15 +1689,45 @@ async fn create_job(Extension(user): Extension, State(app): State,
- /// Written into the job file's frontmatter.
+ name: Option,
+ schedule: Option,
+ agent: Option,
+ /// "" = the server's default model.
+ model: Option,
permission_mode: Option,
+ timeout_minutes: Option,
}
async fn update_job(Extension(user): Extension, State(app): State, Path(id): Path, Json(b): Json) -> ApiResult {
let job = own_job(&app, &user, &id)?;
- if let Some(m) = b.permission_mode.as_deref() {
- crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
- app.audit(Some(&user), "job.permission_mode", &job.path, m);
+ let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
+ let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some();
+ if changes {
+ let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
+ if let Some(x) = b.name {
+ st.name = x;
+ }
+ if let Some(x) = b.schedule {
+ st.schedule = x;
+ }
+ if let Some(x) = b.agent {
+ st.agent = x;
+ }
+ if let Some(x) = b.model {
+ if !x.is_empty() {
+ app.registry().resolve(&x)?;
+ }
+ st.model = Some(x);
+ }
+ if let Some(x) = b.permission_mode {
+ st.permission_mode = x;
+ }
+ if let Some(x) = b.timeout_minutes {
+ st.timeout_minutes = x;
+ }
+ st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
+ app.store.job_set_settings(&job.id, &st)?;
+ app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
}
if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?;
@@ -1678,6 +1736,96 @@ async fn update_job(Extension(user): Extension, State(app): State, State(app): State, Path(id): Path) -> ApiResult {
+ let job = own_job(&app, &user, &id)?;
+ let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
+ let mut st = job.settings.clone().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "automation without settings".into()))?;
+ if let Some(sid) = st.assistant_session.as_deref() {
+ if app.store.get_session(sid)?.is_some_and(|s| s.owner_id.as_deref() == Some(user.id.as_str())) {
+ return Ok(Json(json!({"session_id": sid})));
+ }
+ }
+ let path = std::path::Path::new(&job.path);
+ let cwd = crate::jobs::cwd_of(&job.path, st.cwd.as_deref());
+ let cfg = app.config();
+ let model = st
+ .model
+ .clone()
+ .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
+ .or_else(|| app.registry().first_model())
+ .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
+ let now = now_ms();
+ let instructions = format!(
+ "You help the user write the instructions of the automation \"{name}\". They live in the Markdown file {file} \
+ and are executed unattended by an agent (mode {agent}) on a schedule the user sets in the UI. That run starts a \
+ fresh session and sees only this file – no conversation, nobody to ask.\n\
+ - Work on that file directly with your edit tools and keep it up to date after every round; the user sees it next to this chat.\n\
+ - Find out what the automation should achieve, then shape the file into clear, self-contained instructions: \
+ goal, steps, expected result (e.g. a short report), limits (what must not be done).\n\
+ - Write only the instructions: no frontmatter, no schedule or settings in the file.\n\
+ - Do not carry out the automation's task yourself and do not create other files. Use the language the user writes in.",
+ name = st.name,
+ file = path.display(),
+ agent = st.agent,
+ );
+ let s = Session {
+ id: new_id(),
+ title: format!("{} – {}", st.name, if crate::telegram::german(&app, &user.id) { "Anweisungen" } else { "instructions" }),
+ cwd: cwd.display().to_string(),
+ model,
+ agent: "build".into(),
+ permission_mode: "auto".into(),
+ parent_id: None,
+ owner_id: Some(user.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,
+ telegram_quiet: true,
+ };
+ let s = app.save_session(s)?;
+ st.assistant_session = Some(s.id.clone());
+ app.store.job_set_settings(&job.id, &st)?;
+ Ok(Json(json!({"session_id": s.id})))
+}
+
+#[derive(Deserialize)]
+struct ScheduleQ {
+ expr: String,
+}
+
+/// Check a cron expression and list its next runs (for the schedule editor).
+async fn schedule_preview(Query(q): Query) -> ApiResult {
+ let expr = q.expr.trim();
+ if let Err(e) = crate::jobs::check_schedule(expr) {
+ return Ok(Json(json!({"ok": false, "error": format!("{e:#}")})));
+ }
+ let mut next = Vec::new();
+ let mut t = now_ms();
+ for _ in 0..5 {
+ match crate::jobs::next_run(expr, t) {
+ Some(n) => {
+ next.push(n);
+ t = n + 1000;
+ }
+ None => break,
+ }
+ }
+ // Wall-clock times of the server: cron works in its time zone, the browser may be elsewhere.
+ let local: Vec = next.iter().filter_map(|n| chrono::DateTime::from_timestamp_millis(*n)).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()).collect();
+ let tz = std::env::var("TZ").ok().filter(|z| !z.is_empty()).unwrap_or_else(|| chrono::Local::now().format("UTC%:z").to_string());
+ Ok(Json(json!({"ok": true, "next": next, "next_local": local, "tz": tz})))
+}
+
async fn delete_job(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult {
let job = own_job(&app, &user, &id)?;
app.store.job_delete(&job.id)?;
diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs
index 580e82f..1dbb44c 100644
--- a/server/src/cli_api.rs
+++ b/server/src/cli_api.rs
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension, State(app): State, State(app): State) -> ApiResult {
+ let v: Value = app.store.user_setting(&user.id, "sidebar")?;
+ Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
+}
+
+pub async fn put_sidebar(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult {
+ let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
+ if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
+ return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
+ }
+ let v = json!({"order": b["order"], "pinned": b["pinned"]});
+ app.store.set_user_setting(&user.id, "sidebar", &v)?;
+ Ok(Json(v))
+}
+
/// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension, State(app): State) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?;
diff --git a/server/src/jobs.rs b/server/src/jobs.rs
index 4f7bd29..ee8e11f 100644
--- a/server/src/jobs.rs
+++ b/server/src/jobs.rs
@@ -1,20 +1,23 @@
-//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
-//! its frontmatter starts an agent session at the given times.
+//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
+//! prompt, e.g. `/.codecraft/jobs/.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 # mode (default build)
-//! model: provider/model # optional, default: server default model
-//! permission_mode: auto # default auto; "bypass" runs without asking
-//! timeout_minutes: 60 # optional, default 60
+//! agent: build
+//! permission_mode: auto
+//! timeout_minutes: 60
//! ---
//! Check for outdated dependencies and open a summary …
//! ```
//!
-//! 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.
+//! 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};
@@ -40,6 +43,70 @@ pub struct Job {
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,
+}
+
+/// 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,
+ #[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,
+ /// Chat in which the user works out the prompt with a model.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub assistant_session: Option,
+}
+
+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.
@@ -52,15 +119,89 @@ pub struct JobSpec {
pub permission_mode: String,
pub timeout_minutes: u64,
pub cwd: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub assistant_session: Option,
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 {
+ 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 {
+ 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 {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
- let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
+ // 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: \"\"`", path.display()))?;
- croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
+ 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}'");
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result {
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(),
})
}
@@ -89,7 +231,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option {
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
- if let Some(c) = &spec.cwd {
+ 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) };
}
@@ -106,13 +254,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
/// 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 parse(Path::new(&job.path)) {
+ 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:#}")),
+ 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
}
@@ -125,7 +279,7 @@ pub async fn run(app: &Arc, job: &Job, trigger: &str) -> Result {
/// Start a run; `payload` (webhook body) is appended to the instructions.
pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option) -> Result {
let path = PathBuf::from(&job.path);
- let spec = match parse(&path) {
+ let spec = match spec(job) {
Ok(s) => s,
Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
@@ -204,12 +358,20 @@ pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option<
/// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc) {
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) = parse(Path::new(&job.path)) else { continue };
+ 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 };
@@ -236,22 +398,6 @@ pub fn spawn_scheduler(app: Arc) {
/// 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,
- #[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()
}
@@ -262,71 +408,20 @@ fn default_timeout() -> u64 {
60
}
-/// Write (and validate) a job file.
-pub fn write_file(path: &Path, j: &NewJob) -> Result {
- 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::();
- 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)));
+/// 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 {
+ settings.validate()?;
+ if path.exists() {
+ bail!("{} already exists", path.display());
}
- 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)
- }
- }
-}
-
-/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it
-/// before the closing `---`); the rest of the file stays as it is.
-pub fn set_permission_mode(path: &Path, mode: &str) -> Result {
- if !crate::permission::MODES.contains(&mode) {
- bail!("invalid permission_mode '{mode}'");
- }
- let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
- let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?;
- std::fs::write(path, &updated)?;
- match parse(path) {
- Ok(spec) => Ok(spec),
- Err(e) => {
- let _ = std::fs::write(path, &text);
- Err(e)
- }
- }
-}
-
-fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option {
- let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" };
- let t = &text[bom.len()..];
- let rest = t.strip_prefix("---")?;
- let end = rest.find("\n---")?;
- let (head, tail) = (&rest[..end], &rest[end..]);
- let prefix = format!("{key}:");
- let mut found = false;
- let mut lines: Vec = head
- .split('\n')
- .map(|l| {
- if !found && l.trim_start().starts_with(&prefix) {
- found = true;
- format!("{key}: {value}")
- } else {
- l.to_string()
- }
- })
- .collect();
- if !found {
- lines.push(format!("{key}: {value}"));
- }
- Some(format!("{bom}---{}{tail}", lines.join("\n")))
+ 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 `/.codecraft/jobs/`.
@@ -348,10 +443,11 @@ impl Tool for Schedule {
"schedule"
}
fn description(&self) -> String {
- "Manage automations: agents that run on a cron schedule from a Markdown job file \
- (.codecraft/jobs/.md in the working directory). Actions: \
- `create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
- `register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
+ "Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
+ (.codecraft/jobs/.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."
@@ -394,19 +490,15 @@ impl Tool for Schedule {
let prompt = arg_str(&input, "prompt")?;
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
- 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 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.",
@@ -418,8 +510,10 @@ impl Tool for Schedule {
}
"register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?;
- parse(&path)?;
- let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
+ 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" => {
@@ -491,12 +585,35 @@ mod tests {
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);
- // Added when missing, replaced when present; comments and the prompt stay.
- assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass");
- std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap();
- assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits");
- assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n");
- assert!(set_permission_mode(&p, "yolo").is_err());
+ 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();
}
}
diff --git a/server/src/store.rs b/server/src/store.rs
index 4dda493..7e95745 100644
--- a/server/src/store.rs
+++ b/server/src/store.rs
@@ -169,6 +169,7 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
+ ("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] {
let exists: bool = conn
@@ -1135,13 +1136,14 @@ impl Store {
last_status: r.get(6)?,
created_at: r.get(7)?,
webhook: r.get::<_, Option>(8)?.is_some(),
+ settings: r.get::<_, Option>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
})
}
pub fn jobs(&self, owner: Option<&str>) -> Result> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
- "SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
+ "SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
)?;
let rows = stmt.query_map([owner], Self::job_row)?;
@@ -1151,7 +1153,7 @@ impl Store {
pub fn job(&self, id: &str) -> Result