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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-10 10:05:15 +01:00
1 parent 872ebb94dd
commit 05c215f685
19 files changed
+1661 -356

No files matched your search

+234 -117
View File
@@ -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. `<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 # 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<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.
@@ -52,15 +119,89 @@ pub struct JobSpec {
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);
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: \"<cron>\"`", 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<JobSpec> {
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<i64> {
/// 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<App>, job: &Job, trigger: &str) -> Result<String> {
/// 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 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<App>, job: &Job, trigger: &str, payload: Option<
/// 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) = 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<App>) {
/// Tool: create, list, run and delete automations.
pub struct Schedule;
/// Fields of a job file written by the agent tool or the "from template" dialog.
#[derive(Debug, serde::Deserialize)]
pub struct NewJob {
pub name: String,
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
pub prompt: String,
}
fn default_agent() -> String {
"build".into()
}
@@ -262,71 +408,20 @@ fn default_timeout() -> u64 {
60
}
/// Write (and validate) a job file.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {}\n", plain(m)));
/// 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());
}
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<JobSpec> {
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<String> {
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<String> = 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 `<project>/.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/<name>.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/<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."
@@ -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();
}
}