Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -0,0 +1,376 @@
|
||||
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
|
||||
//! its frontmatter starts an agent session at the given times.
|
||||
//!
|
||||
//! ```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
|
||||
//! ---
|
||||
//! 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.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
use crate::types::{Part, Session, new_id, now_ms};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Job {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub last_run: Option<i64>,
|
||||
pub last_session_id: Option<String>,
|
||||
pub last_status: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Parsed job file.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JobSpec {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub agent: String,
|
||||
pub model: Option<String>,
|
||||
pub permission_mode: String,
|
||||
pub timeout_minutes: u64,
|
||||
pub cwd: Option<String>,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
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();
|
||||
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}"))?;
|
||||
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
||||
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
||||
bail!("invalid permission_mode '{permission_mode}'");
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
bail!("{}: the job has no prompt (text after the frontmatter)", path.display());
|
||||
}
|
||||
Ok(JobSpec {
|
||||
name: meta.get("name").map(unquote).unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("job").to_string()),
|
||||
schedule,
|
||||
agent: meta.get("agent").map(unquote).unwrap_or_else(|| "build".into()),
|
||||
model: meta.get("model").map(unquote).filter(|m| !m.is_empty()),
|
||||
permission_mode,
|
||||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
||||
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||||
prompt: body.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Next run after `after` (ms) in the server's local time zone.
|
||||
pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
|
||||
let cron = croner::Cron::from_str(schedule).ok()?;
|
||||
let start = chrono::DateTime::from_timestamp_millis(after_ms)?.with_timezone(&chrono::Local);
|
||||
cron.find_next_occurrence(&start, false).ok().map(|t| t.timestamp_millis())
|
||||
}
|
||||
|
||||
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
||||
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||
if let Some(c) = &spec.cwd {
|
||||
let p = PathBuf::from(c);
|
||||
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
||||
}
|
||||
let mut dir = path.parent();
|
||||
while let Some(d) = dir {
|
||||
if d.file_name().and_then(|n| n.to_str()) == Some(".codecraft") {
|
||||
return d.parent().unwrap_or(d).to_path_buf();
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
path.parent().unwrap_or(Path::new("/")).to_path_buf()
|
||||
}
|
||||
|
||||
/// Job with its parsed file and next run, for the UI and the tool.
|
||||
pub fn describe(job: &Job) -> Value {
|
||||
let mut v = json!(job);
|
||||
match parse(Path::new(&job.path)) {
|
||||
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:#}")),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Start a run now. Returns the new session id.
|
||||
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
let path = PathBuf::from(&job.path);
|
||||
let spec = match parse(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let owner = app.store.user(&job.owner_id)?.ok_or_else(|| anyhow!("the job's owner no longer exists"))?;
|
||||
if owner.disabled {
|
||||
bail!("the job's owner is disabled");
|
||||
}
|
||||
let cwd = job_cwd(&path, &spec);
|
||||
let roots = app.user_roots(Some(&owner.id));
|
||||
if !roots.iter().any(|r| cwd.starts_with(r)) {
|
||||
bail!("{} is outside the owner's folders", cwd.display());
|
||||
}
|
||||
let cfg = app.config();
|
||||
let model = spec.model.clone().or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?;
|
||||
let now = now_ms();
|
||||
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M");
|
||||
let session = Session {
|
||||
id: new_id(),
|
||||
title: format!("{} – {stamp}", spec.name),
|
||||
cwd: cwd.display().to_string(),
|
||||
model,
|
||||
agent: spec.agent.clone(),
|
||||
permission_mode: spec.permission_mode.clone(),
|
||||
parent_id: None,
|
||||
owner_id: Some(owner.id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
phase: None,
|
||||
};
|
||||
let session = app.save_session(session)?;
|
||||
app.store.job_started(&job.id, &session.id, now)?;
|
||||
let text = format!(
|
||||
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
|
||||
spec.name, job.path, spec.prompt
|
||||
);
|
||||
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
|
||||
|
||||
// Watch for the end (or the timeout).
|
||||
let app2 = app.clone();
|
||||
let job_id = job.id.clone();
|
||||
let sid = session.id.clone();
|
||||
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
|
||||
tokio::spawn(async move {
|
||||
let started = std::time::Instant::now();
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
while app2.is_busy(&sid) {
|
||||
if started.elapsed() > timeout {
|
||||
crate::runner::abort(&app2, &sid);
|
||||
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
|
||||
});
|
||||
Ok(session.id)
|
||||
}
|
||||
|
||||
/// Background scheduler: checks every 30 s which jobs are due.
|
||||
pub fn spawn_scheduler(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
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 };
|
||||
// Due if a scheduled time passed since the last run (or since creation).
|
||||
let base = job.last_run.unwrap_or(job.created_at);
|
||||
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
||||
if next > now {
|
||||
continue;
|
||||
}
|
||||
// Missed runs while the server was down: run once, not for every slot.
|
||||
if job.last_status.as_deref() == Some("running") && job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = run(&app, &job, "schedule").await {
|
||||
tracing::warn!("job {} ({}): {e:#}", job.id, job.path);
|
||||
let _ = app.store.job_finished(&job.id, None, &format!("error: {e:#}"));
|
||||
// Avoid retrying every 30 s.
|
||||
let _ = app.store.job_touch(&job.id, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- tool -------------------------------------------------------------------------
|
||||
|
||||
/// Tool: create, list, run and delete automations.
|
||||
pub struct Schedule;
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
let out: String = s.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
|
||||
out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Schedule {
|
||||
fn name(&self) -> &str {
|
||||
"schedule"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Manage automations: agents that run on a cron schedule 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; \
|
||||
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
||||
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
||||
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["create", "register", "list", "run", "enable", "disable", "delete"]},
|
||||
"name": {"type": "string"},
|
||||
"schedule": {"type": "string", "description": "cron, e.g. \"0 3 * * *\""},
|
||||
"prompt": {"type": "string", "description": "Complete instructions for the scheduled agent"},
|
||||
"agent": {"type": "string", "description": "Mode, default build"},
|
||||
"model": {"type": "string"},
|
||||
"permission_mode": {"type": "string", "enum": crate::permission::MODES},
|
||||
"timeout_minutes": {"type": "integer"},
|
||||
"path": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"delete_file": {"type": "boolean"}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Automation: {} {}", input["action"].as_str().unwrap_or(""), input["name"].as_str().or(input["id"].as_str()).unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).ok_or_else(|| anyhow!("session has no owner"))?;
|
||||
let own_job = |id: &str| -> Result<Job> {
|
||||
ctx.app.store.job(id)?.filter(|j| j.owner_id == owner).ok_or_else(|| anyhow!("no automation with id {id}"))
|
||||
};
|
||||
match arg_str(&input, "action")? {
|
||||
"create" => {
|
||||
let name = arg_str(&input, "name")?;
|
||||
let schedule = arg_str(&input, "schedule")?;
|
||||
let prompt = arg_str(&input, "prompt")?;
|
||||
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||
let q = |s: &str| format!("\"{}\"", s.replace('"', "'"));
|
||||
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(name), q(schedule), input["agent"].as_str().unwrap_or("build"));
|
||||
if let Some(m) = input["model"].as_str().filter(|m| !m.is_empty()) {
|
||||
fm.push_str(&format!("model: {m}\n"));
|
||||
}
|
||||
fm.push_str(&format!("permission_mode: {}\n", input["permission_mode"].as_str().unwrap_or("auto")));
|
||||
fm.push_str(&format!("timeout_minutes: {}\n---\n", input["timeout_minutes"].as_u64().unwrap_or(60)));
|
||||
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
||||
let path = ctx.resolve(&rel)?;
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
std::fs::write(&path, format!("{fm}{}\n", prompt.trim()))?;
|
||||
parse(&path)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
let d = describe(&job);
|
||||
Ok(ToolOutput::text(format!(
|
||||
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
||||
rel,
|
||||
job.id,
|
||||
fmt_time(d["next_run"].as_i64())
|
||||
))
|
||||
.with_meta(d))
|
||||
}
|
||||
"register" => {
|
||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
parse(&path)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
||||
}
|
||||
"list" => {
|
||||
let jobs = ctx.app.store.jobs(Some(&owner))?;
|
||||
if jobs.is_empty() {
|
||||
return Ok(ToolOutput::text("No automations."));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for j in &jobs {
|
||||
let d = describe(j);
|
||||
out.push_str(&format!(
|
||||
"- {} [{}] {} – schedule {} – next {} – last {} ({})\n",
|
||||
j.id,
|
||||
if j.enabled { "on" } else { "off" },
|
||||
d["spec"]["name"].as_str().unwrap_or("?"),
|
||||
d["spec"]["schedule"].as_str().unwrap_or("?"),
|
||||
fmt_time(d["next_run"].as_i64()),
|
||||
fmt_time(j.last_run),
|
||||
j.last_status.as_deref().unwrap_or("-"),
|
||||
));
|
||||
}
|
||||
Ok(ToolOutput::text(out))
|
||||
}
|
||||
"run" => {
|
||||
let job = own_job(arg_str(&input, "id")?)?;
|
||||
let sid = run(&ctx.app, &job, "manual").await?;
|
||||
Ok(ToolOutput::text(format!("Started run in session {sid}.")))
|
||||
}
|
||||
a @ ("enable" | "disable") => {
|
||||
let job = own_job(arg_str(&input, "id")?)?;
|
||||
ctx.app.store.job_set_enabled(&job.id, a == "enable")?;
|
||||
Ok(ToolOutput::text(format!("Automation {} {a}d.", job.id)))
|
||||
}
|
||||
"delete" => {
|
||||
let job = own_job(arg_str(&input, "id")?)?;
|
||||
ctx.app.store.job_delete(&job.id)?;
|
||||
if input["delete_file"].as_bool().unwrap_or(false) {
|
||||
let _ = std::fs::remove_file(&job.path);
|
||||
}
|
||||
Ok(ToolOutput::text(format!("Automation {} deleted.", job.id)))
|
||||
}
|
||||
other => bail!("unknown action '{other}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_time(ms: Option<i64>) -> String {
|
||||
ms.and_then(chrono::DateTime::from_timestamp_millis).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M %Z").to_string()).unwrap_or_else(|| "-".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn next_run_works() {
|
||||
let base = chrono::DateTime::parse_from_rfc3339("2026-11-08T10:15:00+00:00").unwrap().timestamp_millis();
|
||||
let n = next_run("*/30 * * * *", base).unwrap();
|
||||
assert_eq!(n - base, 15 * 60 * 1000);
|
||||
assert!(next_run("not a cron", base).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_job_file() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-job-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||||
let p = dir.join(".codecraft/jobs/x.md");
|
||||
std::fs::write(&p, "---\nname: \"Nightly\"\nschedule: \"0 3 * * *\"\n---\nDo things.\n").unwrap();
|
||||
let s = parse(&p).unwrap();
|
||||
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
|
||||
assert_eq!(job_cwd(&p, &s), dir);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user