From 05c215f685b8c6f359a319becb13bb6ef9f0dacb Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 10 Nov 2026 10:05:15 +0100 Subject: [PATCH] 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/.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 --- CHANGELOG.md | 22 ++ README.md | 14 +- server/src/api.rs | 178 +++++++++++- server/src/cli_api.rs | 17 ++ server/src/jobs.rs | 351 +++++++++++++++-------- server/src/store.rs | 13 +- server/src/telegram.rs | 3 +- web/src/App.tsx | 2 + web/src/components/CronBuilder.tsx | 217 ++++++++++++++ web/src/components/JobEditor.tsx | 298 +++++++++++++++++++ web/src/components/JobTemplateDialog.tsx | 134 --------- web/src/components/Jobs.tsx | 56 ++-- web/src/components/NewJobDialog.tsx | 128 +++++++++ web/src/components/Sidebar.tsx | 208 +++++++++++++- web/src/i18n/de.ts | 63 +++- web/src/i18n/en.ts | 63 +++- web/src/jobTemplates.ts | 10 - web/src/styles.css | 230 +++++++++++++++ web/src/whatsnew.ts | 10 + 19 files changed, 1661 insertions(+), 356 deletions(-) create mode 100644 web/src/components/CronBuilder.tsx create mode 100644 web/src/components/JobEditor.tsx delete mode 100644 web/src/components/JobTemplateDialog.tsx create mode 100644 web/src/components/NewJobDialog.tsx 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 Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent

-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> { let conn = self.conn.lock().unwrap(); Ok(conn - .query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row) + .query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row) .optional()?) } @@ -1167,7 +1169,7 @@ impl Store { } let conn = self.conn.lock().unwrap(); Ok(conn.query_row( - "SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1", + "SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1", [path], Self::job_row, )?) @@ -1200,6 +1202,11 @@ impl Store { } /// Set (Some) or remove (None) the webhook secret of a job. + pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> { + self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?; + Ok(()) + } + pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> { self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?; Ok(()) diff --git a/server/src/telegram.rs b/server/src/telegram.rs index 886d412..a3dc1d8 100644 --- a/server/src/telegram.rs +++ b/server/src/telegram.rs @@ -260,7 +260,8 @@ fn user(app: &App, user_id: &str) -> Result { app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled")) } -fn german(app: &App, user_id: &str) -> bool { +/// The user reads German (language preference of the web UI). +pub fn german(app: &App, user_id: &str) -> bool { let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default(); prefs["lang"].as_str().unwrap_or("en").starts_with("de") } diff --git a/web/src/App.tsx b/web/src/App.tsx index addcfc1..cb36903 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { Settings } from "./components/Settings"; import { Stats } from "./components/Stats"; import { Images } from "./components/Images"; import { Jobs } from "./components/Jobs"; +import { JobEditor } from "./components/JobEditor"; import { Spinner, useTitleActivity } from "./components/Activity"; import { DialogHost } from "./dialogs"; import { startPrefsSync } from "./prefsSync"; @@ -95,6 +96,7 @@ export function App() { else if (section === "settings") content = setMenuOpen(true)} />; else if (section === "stats") content = setMenuOpen(true)} />; else if (section === "images") content = setMenuOpen(true)} />; + else if (section === "jobs" && arg) content = setMenuOpen(true)} />; else if (section === "jobs") content = setMenuOpen(true)} />; else if (section === "terminal") content = ( diff --git a/web/src/components/CronBuilder.tsx b/web/src/components/CronBuilder.tsx new file mode 100644 index 0000000..80a14ed --- /dev/null +++ b/web/src/components/CronBuilder.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { get } from "../api"; +import { getLang, useT } from "../i18n"; + +/** + * Graphical editor for a 5-field cron expression (minute hour day month weekday, + * server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a + * custom expression. Shows the next runs as computed by the server. + */ +type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom"; + +interface State { + mode: Mode; + every: number; + minute: number; + time: string; // HH:MM + days: number[]; // cron weekdays, 0 = Sunday + dom: number; + custom: string; +} + +const MINUTE_STEPS = [5, 10, 15, 20, 30]; +/** Monday first; cron numbers (0 = Sunday). */ +const WEEK = [1, 2, 3, 4, 5, 6, 0]; + +const pad = (n: number) => String(n).padStart(2, "0"); +const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`; + +function expandDays(field: string): number[] | null { + const out = new Set(); + for (const part of field.split(",")) { + const m = part.match(/^(\d)(?:-(\d))?$/); + if (!m) return null; + const a = Number(m[1]); + const b = m[2] ? Number(m[2]) : a; + if (a > 7 || b > 7 || b < a) return null; + for (let d = a; d <= b; d++) out.add(d % 7); + } + return [...out]; +} + +export function parseCron(expr: string): State { + const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() }; + const f = expr.trim().split(/\s+/); + if (f.length !== 5) return base; + const [mi, h, dom, mon, dow] = f; + const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max; + if (mon !== "*") return base; + let m; + if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) }; + if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) }; + if (!num(mi, 59) || !num(h, 23)) return base; + const time = hhmm(h, mi); + if (dom === "*" && dow === "*") return { ...base, mode: "daily", time }; + if (dom === "*") { + const days = expandDays(dow); + return days?.length ? { ...base, mode: "weekly", time, days } : base; + } + if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) }; + return base; +} + +export function buildCron(s: State): string { + const [h, m] = s.time.split(":").map(Number); + switch (s.mode) { + case "minutes": + return `*/${s.every} * * * *`; + case "hourly": + return `${s.minute} * * * *`; + case "daily": + return `${m} ${h} * * *`; + case "weekly": { + const days = [...s.days].sort((a, b) => a - b); + return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`; + } + case "monthly": + return `${m} ${h} ${s.dom} * *`; + default: + return s.custom.trim(); + } +} + +const dayName = (d: number, style: "short" | "long") => { + // 2023-01-01 was a Sunday. + return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style }); +}; + +/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */ +const fmtWall = (s: string) => { + const [d, hm] = s.split("T"); + const [y, mo, da] = d.split("-").map(Number); + const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" }); + return `${date}, ${hm}`; +}; + +export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) { + const t = useT(); + const [s, setS] = useState(() => parseCron(value)); + const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>(); + + // A value from outside (another automation, reset) that the builder did not produce. + useEffect(() => { + if (value.trim() !== buildCron(s)) setS(parseCron(value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + useEffect(() => { + const expr = value.trim(); + if (!expr) return setPreview(undefined); + let alive = true; + const h = setTimeout(() => { + get(`/jobs/schedule?expr=${encodeURIComponent(expr)}`) + .then((p) => alive && setPreview(p)) + .catch(() => alive && setPreview(undefined)); + }, 250); + return () => { + alive = false; + clearTimeout(h); + }; + }, [value]); + + const set = (patch: Partial) => { + const next = { ...s, ...patch }; + // Switching to "custom" starts from the current expression. + if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s); + setS(next); + onChange(buildCron(next)); + }; + + const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"]; + return ( +
+
+ {modes.map((m) => ( + + ))} +
+
+ {s.mode === "minutes" && ( + + )} + {s.mode === "hourly" && ( + + )} + {s.mode === "weekly" && ( +
+ {WEEK.map((d) => ( + + ))} + +
+ )} + {s.mode === "monthly" && ( + + )} + {(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && ( + + )} + {s.mode === "custom" && ( + + )} +
+
+ {value || "–"} + {preview?.ok === false && {preview.error}} + {preview?.ok && preview.next_local && preview.next_local.length > 0 && ( + + {t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")} + + )} + {preview?.tz && s.mode !== "minutes" && {t("cron.serverTz", { tz: preview.tz })}} + {s.mode === "monthly" && s.dom > 28 && {t("cron.shortMonths")}} +
+
+ ); +} diff --git a/web/src/components/JobEditor.tsx b/web/src/components/JobEditor.tsx new file mode 100644 index 0000000..5efa2ac --- /dev/null +++ b/web/src/components/JobEditor.tsx @@ -0,0 +1,298 @@ +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { ApiError, get, patch, post, put } from "../api"; +import { confirmDialog, toast } from "../dialogs"; +import { fmtRelative, useT } from "../i18n"; +import { navigate } from "../router"; +import { useStore } from "../store"; +import { Spinner } from "./Activity"; +import { ChatView } from "./ChatView"; +import { CronBuilder } from "./CronBuilder"; +import { Icon } from "./Icon"; +import type { JobInfo } from "./Jobs"; +import { ModelSelect } from "./ModelSelect"; + +const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor }))); + +interface Settings { + name: string; + schedule: string; + agent: string; + model: string; + permission_mode: string; + timeout_minutes: number; +} + +const settingsOf = (j: JobInfo): Settings => ({ + name: j.spec?.name ?? "", + schedule: j.spec?.schedule ?? "0 3 * * *", + agent: j.spec?.agent ?? "build", + model: j.spec?.model ?? "", + permission_mode: j.spec?.permission_mode ?? "auto", + timeout_minutes: j.spec?.timeout_minutes ?? 60, +}); + +type Tab = "prompt" | "settings" | "assistant"; + +/** + * One automation: its instructions (Markdown file) in an editor, schedule and settings, + * and a chat with a model that helps to write the instructions (it edits the file). + */ +export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) { + const t = useT(); + const [job, setJob] = useState(); + const [settings, setSettings] = useState(); + const [saved, setSaved] = useState(); + const [text, setText] = useState(); + const [savedText, setSavedText] = useState(); + const [mtime, setMtime] = useState(); + const [changedOnDisk, setChangedOnDisk] = useState(false); + const [saving, setSaving] = useState(false); + const [assistant, setAssistant] = useState(); + const [tab, setTab] = useState("prompt"); + + const loadJob = useCallback(async () => { + const j = await get(`/jobs/${id}`); + setJob(j); + const s = settingsOf(j); + setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s)); + setSaved(s); + setAssistant(j.spec?.assistant_session ?? undefined); + return j; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const loadFile = useCallback(async (path: string) => { + const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`); + setText(f.content ?? ""); + setSavedText(f.content ?? ""); + setMtime(f.mtime); + setChangedOnDisk(false); + }, []); + + useEffect(() => { + loadJob() + .then((j) => loadFile(j.path)) + .catch((e) => toast(e.message)); + }, [loadJob, loadFile]); + + const fileDirty = text !== undefined && text !== savedText; + const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved); + const dirty = fileDirty || settingsDirty; + + // The assistant edits the file: reload it when a turn ends (unless there are unsaved edits). + const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false)); + const wasBusy = useRef(false); + const dirtyRef = useRef(fileDirty); + dirtyRef.current = fileDirty; + useEffect(() => { + if (wasBusy.current && !busy && job) { + get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`) + .then((st) => { + if (st.mtime && mtime && st.mtime <= mtime) return; + if (dirtyRef.current) setChangedOnDisk(true); + else void loadFile(job.path); + }) + .catch(() => undefined); + } + wasBusy.current = busy; + }, [busy, job, mtime, loadFile]); + + // Unsaved changes: ask before leaving the page. + useEffect(() => { + if (!dirty) return; + const warn = (e: BeforeUnloadEvent) => e.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + + const save = async (force = false) => { + if (!job || !settings) return; + setSaving(true); + try { + if (settingsDirty) { + const j = await patch(`/jobs/${id}`, settings); + setJob(j); + const s = settingsOf(j); + setSettings(s); + setSaved(s); + } + if (fileDirty) { + const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force }); + setSavedText(text); + setMtime(r.mtime); + setChangedOnDisk(false); + void loadJob(); + } + toast(t("jobEditor.saved"), "success"); + } catch (e: any) { + if (e instanceof ApiError && e.status === 409) { + if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true); + } else toast(e.message); + } finally { + setSaving(false); + } + }; + + const setEnabled = async (enabled: boolean) => { + try { + setJob(await patch(`/jobs/${id}`, { enabled })); + } catch (e: any) { + toast(e.message); + } + }; + + const runNow = async () => { + if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return; + try { + const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {}); + navigate(`/s/${r.session_id}`); + } catch (e: any) { + toast(e.message); + } + }; + + const startAssistant = async () => { + try { + const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {}); + setAssistant(r.session_id); + setTab("assistant"); + } catch (e: any) { + toast(e.message); + } + }; + + const back = async () => { + if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return; + navigate("/jobs"); + }; + + if (!job || !settings) return
; + const up = (p: Partial) => setSettings({ ...settings, ...p }); + + return ( + <> +
+ + +
+

{settings.name || t("nav.jobs")}

+
{job.path}
+
+ + + +
+
+ {(["prompt", "settings", "assistant"] as Tab[]).map((x) => ( + + ))} +
+
+
+
+
+ +
+ + {t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"} + + + {t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"} + {job.last_session_id && ( + + )} + +
+
+
+ {t("jobs.schedule")} + up({ schedule })} /> +
+
+ + + + +
+ {settings.permission_mode !== "bypass" &&
{t("jobEditor.bypassHint")}
} +
+
+
+ {t("jobEditor.instructions")} + {t("jobEditor.instructionsHint")} +
+ {changedOnDisk && ( +
+ {t("jobEditor.changedOnDisk")}{" "} + +
+ )} + {job.error && !job.error.includes("empty") &&
{job.error}
} + {text === undefined ? ( +
+ ) : ( +
}> + void save()} /> + + )} + +
+ + + + ); +} diff --git a/web/src/components/JobTemplateDialog.tsx b/web/src/components/JobTemplateDialog.tsx deleted file mode 100644 index c7e395d..0000000 --- a/web/src/components/JobTemplateDialog.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useEffect, useState } from "react"; -import { useFetch } from "../hooks"; -import { post } from "../api"; -import { getLang, useT } from "../i18n"; -import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates"; -import { Icon } from "./Icon"; -import { ModelSelect } from "./ModelSelect"; -import { FolderPicker } from "./NewSession"; - -/** Create an automation from a template: pick it, choose the project, adjust, create. */ -export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { - const t = useT(); - const de = getLang() === "de"; - const [tpl, setTpl] = useState(); - const [name, setName] = useState(""); - const [schedule, setSchedule] = useState(""); - const [agent, setAgent] = useState("build"); - const [mode, setMode] = useState("auto"); - const [model, setModel] = useState(""); - const [prompt, setPrompt] = useState(""); - const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? ""); - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - const config = useFetch<{ default_model?: string }>("/config").data; - useEffect(() => { - if (!model && config?.default_model) setModel(config.default_model); - }, [config, model]); - - const choose = (x: JobTemplate) => { - setTpl(x); - setName(de ? x.name.de : x.name.en); - setSchedule(x.schedule); - setAgent(x.agent); - setPrompt(x.prompt); - }; - - const create = async () => { - setBusy(true); - setError(undefined); - try { - await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt }); - onCreated(); - onClose(); - } catch (e: any) { - setError(e.message); - } finally { - setBusy(false); - } - }; - - return ( -
-
e.stopPropagation()}> - {!tpl ? ( - <> -

{t("jobs.fromTemplate")}

-
- {JOB_TEMPLATES.map((x) => ( - - ))} -
-
- -
- - ) : ( - <> -

- {de ? tpl.name.de : tpl.name.en} -

- -
- {t("jobs.tplProject")} - -
-
- {t("jobs.tplSchedule")} -
- {SCHEDULES.map((s) => ( - - ))} -
- setSchedule(e.target.value)} placeholder="0 3 * * *" /> - {t("jobs.tplCronHint")} -
-
- - - -
-