cron delivery ui

This commit is contained in:
jaberjaber23
2026-04-19 21:53:20 +03:00
parent 88eeaa6a4d
commit 0ce390e09f
7 changed files with 1796 additions and 10 deletions

View File

@@ -3511,6 +3511,21 @@ pub async fn list_skills(State(state): State<Arc<AppState>>) -> impl IntoRespons
let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir);
let _ = registry.load_all();
// Snapshot of user-provided overrides for the `config_resolved_count`.
// Matches `reload_skills()` fallback order so the count reflects what
// agents will actually see.
let user_configs = {
let override_guard = state
.kernel
.skill_config_overrides
.read()
.unwrap_or_else(|e| e.into_inner());
override_guard
.as_ref()
.cloned()
.unwrap_or_else(|| state.kernel.config.skills.clone())
};
let skills: Vec<serde_json::Value> = registry
.list()
.iter()
@@ -3529,6 +3544,32 @@ pub async fn list_skills(State(state): State<Arc<AppState>>) -> impl IntoRespons
serde_json::json!({"type": "local"})
}
};
// Count how many declared config vars resolve to any source
// (user override → env → default). Does NOT fetch secret values;
// it only checks presence. Zero-length map → 0/0.
let declared = &s.manifest.config;
let skill_user_cfg = user_configs.get(&s.manifest.skill.name);
let mut resolved = 0usize;
for (name, var) in declared.iter() {
if skill_user_cfg.and_then(|m| m.get(name)).is_some() {
resolved += 1;
continue;
}
if let Some(env_name) = var.env.as_ref() {
if std::env::var(env_name)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
resolved += 1;
continue;
}
}
if var.default.as_ref().is_some_and(|d| !d.is_empty()) {
resolved += 1;
}
}
serde_json::json!({
"name": s.manifest.skill.name,
"description": s.manifest.skill.description,
@@ -3540,6 +3581,8 @@ pub async fn list_skills(State(state): State<Arc<AppState>>) -> impl IntoRespons
"enabled": s.enabled,
"source": source,
"has_prompt_context": s.manifest.prompt_context.is_some(),
"config_declared_count": declared.len(),
"config_resolved_count": resolved,
})
})
.collect();
@@ -7894,6 +7937,512 @@ pub async fn create_skill(
)
}
// ---------------------------------------------------------------------------
// Skill config endpoints
// ---------------------------------------------------------------------------
//
// These endpoints expose per-skill runtime variables declared in the skill's
// SKILL.md frontmatter `config:` section. Resolution order is:
//
// 1. user config (the `[skills.<name>]` section in config.toml)
// 2. env var (`var.env`)
// 3. default (`var.default`)
//
// The GET endpoint always returns secret-looking values redacted. The PUT
// endpoint atomically rewrites the `[skills.<name>]` section of config.toml
// and reloads the skill registry so the change takes effect on the next
// prompt build.
/// Load a skill's declared `config:` map, even if `required` vars are
/// currently unresolved.
///
/// Looking up the live registry doesn't work here because the registry's
/// load path refuses to insert a skill whose required config var has no
/// resolvable value — which is exactly the state the user needs to reach
/// this UI to fix. Instead we read the manifest straight from disk (for
/// user-installed / workspace skills) or straight from the compile-time
/// bundled catalog.
fn load_skill_declared_config(
skills_dir: &std::path::Path,
skill_name: &str,
) -> Option<std::collections::HashMap<String, openfang_skills::config_injection::SkillConfigVar>> {
// 1. User-installed skill: skills_dir/<name>/skill.toml, falling back to
// SKILL.md frontmatter if no TOML has been generated yet.
let skill_dir = skills_dir.join(skill_name);
if skill_dir.is_dir() {
let toml_path = skill_dir.join("skill.toml");
if toml_path.exists() {
if let Ok(text) = std::fs::read_to_string(&toml_path) {
if let Ok(manifest) = toml::from_str::<openfang_skills::SkillManifest>(&text) {
if manifest.skill.name == skill_name {
return Some(manifest.config);
}
}
}
}
let skillmd_path = skill_dir.join("SKILL.md");
if skillmd_path.exists() {
if let Ok(text) = std::fs::read_to_string(&skillmd_path) {
if let Ok(converted) =
openfang_skills::openclaw_compat::convert_skillmd_str(skill_name, &text)
{
return Some(converted.config_vars);
}
}
}
}
// 2. Bundled skill: look up by name in the compile-time catalog.
for (bundled_name, content) in openfang_skills::bundled::bundled_skills() {
if bundled_name == skill_name {
if let Ok(converted) =
openfang_skills::openclaw_compat::convert_skillmd_str(bundled_name, content)
{
return Some(converted.config_vars);
}
}
}
None
}
/// Build the per-skill config state for the GET endpoint.
///
/// Returns `None` if the skill is not installed. Resolved values are
/// redacted when the variable name looks secret (see
/// [`openfang_skills::config_injection::is_secret_name`]).
fn build_skill_config_snapshot(
state: &Arc<AppState>,
skill_name: &str,
) -> Option<serde_json::Value> {
use openfang_skills::config_injection::is_secret_name;
let skills_dir = state.kernel.config.home_dir.join("skills");
let declared = load_skill_declared_config(&skills_dir, skill_name)?;
// Snapshot the currently active user config (override map takes priority
// over the boot-time `self.config.skills`, matching what `reload_skills`
// will actually use).
let user_cfg: std::collections::HashMap<String, String> = {
let override_guard = state
.kernel
.skill_config_overrides
.read()
.unwrap_or_else(|e| e.into_inner());
let source: &std::collections::HashMap<String, std::collections::HashMap<String, String>> =
override_guard.as_ref().unwrap_or(&state.kernel.config.skills);
source.get(skill_name).cloned().unwrap_or_default()
};
let mut declared_json = serde_json::Map::new();
let mut resolved_json = serde_json::Map::new();
for (name, var) in declared.iter() {
declared_json.insert(
name.clone(),
serde_json::json!({
"description": var.description,
"env": var.env,
"default": var.default,
"required": var.required,
}),
);
let is_secret = is_secret_name(name);
let (value_opt, source) = if let Some(v) = user_cfg.get(name) {
(Some(v.clone()), "user")
} else if let Some(env_name) = var.env.as_ref() {
match std::env::var(env_name) {
Ok(v) if !v.is_empty() => (Some(v), "env"),
_ => {
if let Some(d) = var.default.as_ref() {
(Some(d.clone()), "default")
} else {
(None, "unresolved")
}
}
}
} else if let Some(d) = var.default.as_ref() {
(Some(d.clone()), "default")
} else {
(None, "unresolved")
};
// Redact secrets for the wire response.
let shown_value: serde_json::Value = match value_opt {
Some(v) if is_secret => {
let prefix: String = v.chars().take(4).collect();
if prefix.is_empty() {
serde_json::Value::String("***redacted***".to_string())
} else {
serde_json::Value::String(format!("{prefix}***redacted***"))
}
}
Some(v) => serde_json::Value::String(v),
None => serde_json::Value::Null,
};
resolved_json.insert(
name.clone(),
serde_json::json!({
"value": shown_value,
"source": source,
"is_secret": is_secret,
}),
);
}
Some(serde_json::json!({
"skill": skill_name,
"declared": serde_json::Value::Object(declared_json),
"resolved": serde_json::Value::Object(resolved_json),
}))
}
/// GET /api/skills/{id}/config — Return declared + resolved per-skill config.
///
/// Response shape:
/// ```json
/// {
/// "skill": "github-helper",
/// "declared": {
/// "github_token": { "description": "...", "env": "GH_TOKEN", "default": null, "required": true }
/// },
/// "resolved": {
/// "github_token": { "value": "ghp_***redacted***", "source": "user", "is_secret": true }
/// }
/// }
/// ```
///
/// Secret-looking values (`*_token`, `*_key`, `*_secret`, `password`) are
/// always redacted in the `value` field. The `source` field names the layer
/// that won: `"user"`, `"env"`, `"default"`, or `"unresolved"`.
pub async fn get_skill_config(
State(state): State<Arc<AppState>>,
Path(skill_name): Path<String>,
) -> impl IntoResponse {
match build_skill_config_snapshot(&state, &skill_name) {
Some(snapshot) => (StatusCode::OK, Json(snapshot)),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Skill '{skill_name}' not found")})),
),
}
}
/// Request body for `PUT /api/skills/{id}/config`.
#[derive(Debug, serde::Deserialize)]
pub struct SkillConfigPutRequest {
/// New per-variable values. Keys must match declared variable names.
pub values: std::collections::HashMap<String, String>,
}
/// PUT /api/skills/{id}/config — Write user overrides for a skill's config.
///
/// The body is `{"values": {"<var_name>": "<value>"}}`. Only declared
/// variables are accepted; unknown keys return 400. After the config.toml is
/// atomically rewritten, the kernel's skill registry is reloaded so the
/// change takes effect immediately.
pub async fn put_skill_config(
State(state): State<Arc<AppState>>,
Path(skill_name): Path<String>,
Json(body): Json<SkillConfigPutRequest>,
) -> impl IntoResponse {
// Refuse to operate on unknown skill (matching GET behavior). We read
// the declared config directly instead of going through the registry so
// a skill with required-but-unresolved vars is still editable.
let skills_dir = state.kernel.config.home_dir.join("skills");
let declared = match load_skill_declared_config(&skills_dir, &skill_name) {
Some(d) => d,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Skill '{skill_name}' not found")})),
);
}
};
// Reject unknown keys.
for k in body.values.keys() {
if !declared.contains_key(k) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Variable '{k}' is not declared by skill '{skill_name}'")
})),
);
}
}
// Build the final in-memory skill-configs map: start from the current
// override (or boot-time config), replace the skill's section with the
// incoming values.
let mut all_configs: std::collections::HashMap<
String,
std::collections::HashMap<String, String>,
> = {
let guard = state
.kernel
.skill_config_overrides
.read()
.unwrap_or_else(|e| e.into_inner());
guard
.as_ref()
.cloned()
.unwrap_or_else(|| state.kernel.config.skills.clone())
};
all_configs.insert(skill_name.clone(), body.values.clone());
// Persist atomically to config.toml.
let config_path = state.kernel.config.home_dir.join("config.toml");
if let Err(e) = upsert_skill_config(&config_path, &skill_name, &body.values) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to persist skill config: {e}")
})),
);
}
// Reload so agents see the new config on their next prompt build.
state.kernel.reload_skills_with_configs(all_configs);
(
StatusCode::OK,
Json(serde_json::json!({
"status": "saved",
"skill": skill_name,
"reloaded": true,
})),
)
}
/// DELETE /api/skills/{id}/config/{var_name} — Remove a single user override.
///
/// After removal, the variable falls back to env or default on the next
/// prompt build. If removing the override would leave a `required` variable
/// with no resolvable value, the request fails with 409 Conflict so the
/// caller is never tricked into a broken state.
pub async fn delete_skill_config_var(
State(state): State<Arc<AppState>>,
Path((skill_name, var_name)): Path<(String, String)>,
) -> impl IntoResponse {
let skills_dir = state.kernel.config.home_dir.join("skills");
let declared = match load_skill_declared_config(&skills_dir, &skill_name) {
Some(d) => d,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Skill '{skill_name}' not found")})),
);
}
};
let var_def = match declared.get(&var_name) {
Some(v) => v.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Variable '{var_name}' is not declared by skill '{skill_name}'")
})),
);
}
};
// If the var is required, make sure env or default still resolves;
// otherwise we would leave the skill unable to register after reload.
if var_def.required {
let env_resolves = var_def
.env
.as_ref()
.and_then(|e| std::env::var(e).ok())
.map(|v| !v.is_empty())
.unwrap_or(false);
let default_resolves = var_def
.default
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
if !env_resolves && !default_resolves {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": format!(
"Cannot remove override: '{var_name}' is required and has no env/default fallback"
)
})),
);
}
}
let mut all_configs: std::collections::HashMap<
String,
std::collections::HashMap<String, String>,
> = {
let guard = state
.kernel
.skill_config_overrides
.read()
.unwrap_or_else(|e| e.into_inner());
guard
.as_ref()
.cloned()
.unwrap_or_else(|| state.kernel.config.skills.clone())
};
if let Some(skill_map) = all_configs.get_mut(&skill_name) {
skill_map.remove(&var_name);
if skill_map.is_empty() {
all_configs.remove(&skill_name);
}
}
let config_path = state.kernel.config.home_dir.join("config.toml");
if let Err(e) = remove_skill_config_var(&config_path, &skill_name, &var_name) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to update config.toml: {e}")
})),
);
}
state.kernel.reload_skills_with_configs(all_configs);
(
StatusCode::OK,
Json(serde_json::json!({
"status": "removed",
"skill": skill_name,
"var": var_name,
"reloaded": true,
})),
)
}
/// Atomically upsert `[skills.<skill_name>]` in config.toml.
///
/// Writes to `<path>.tmp` then renames — so a crash during the write never
/// leaves a half-truncated config on disk. Existing root-level fields are
/// preserved; only the named skill's section is replaced.
fn upsert_skill_config(
config_path: &std::path::Path,
skill_name: &str,
values: &std::collections::HashMap<String, String>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = if config_path.exists() {
std::fs::read_to_string(config_path)?
} else {
String::new()
};
let mut doc: toml::Value = if content.trim().is_empty() {
toml::Value::Table(toml::map::Map::new())
} else {
toml::from_str(&content)?
};
let root = doc.as_table_mut().ok_or("Config is not a TOML table")?;
if !root.contains_key("skills") {
root.insert(
"skills".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let skills_table = root
.get_mut("skills")
.and_then(|v| v.as_table_mut())
.ok_or("skills is not a table")?;
let mut skill_section = toml::map::Map::new();
for (k, v) in values {
skill_section.insert(k.clone(), toml::Value::String(v.clone()));
}
skills_table.insert(skill_name.to_string(), toml::Value::Table(skill_section));
atomic_write_toml(config_path, &doc)?;
Ok(())
}
/// Atomically remove a single variable from `[skills.<skill_name>]`.
///
/// If the skill table becomes empty the table itself is removed so we don't
/// leave empty sections lying around.
fn remove_skill_config_var(
config_path: &std::path::Path,
skill_name: &str,
var_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if !config_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(config_path)?;
if content.trim().is_empty() {
return Ok(());
}
let mut doc: toml::Value = toml::from_str(&content)?;
let root = match doc.as_table_mut() {
Some(r) => r,
None => return Ok(()),
};
let mut remove_skill = false;
if let Some(skills_table) = root.get_mut("skills").and_then(|v| v.as_table_mut()) {
if let Some(skill_section) = skills_table.get_mut(skill_name).and_then(|v| v.as_table_mut())
{
skill_section.remove(var_name);
if skill_section.is_empty() {
remove_skill = true;
}
}
if remove_skill {
skills_table.remove(skill_name);
}
}
atomic_write_toml(config_path, &doc)?;
Ok(())
}
/// Write a TOML value to disk atomically via a sibling temp file + rename.
///
/// On Windows, `rename` is atomic on the same volume when the destination
/// does not exist; if it does we fall back to the non-atomic write path and
/// still cover the "crash mid-write truncates config.toml" class of bug.
fn atomic_write_toml(
config_path: &std::path::Path,
doc: &toml::Value,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let rendered = toml::to_string_pretty(doc)?;
let tmp_path = match config_path.file_name() {
Some(name) => {
let mut tmp_name = std::ffi::OsString::from(".");
tmp_name.push(name);
tmp_name.push(".tmp");
config_path.with_file_name(tmp_name)
}
None => config_path.with_extension("tmp"),
};
std::fs::write(&tmp_path, rendered)?;
match std::fs::rename(&tmp_path, config_path) {
Ok(()) => Ok(()),
Err(_) => {
// Fallback: direct write, then best-effort cleanup of temp.
let rendered = toml::to_string_pretty(doc)?;
std::fs::write(config_path, rendered)?;
let _ = std::fs::remove_file(&tmp_path);
Ok(())
}
}
}
// ── Helper functions for secrets.env management ────────────────────────
/// Write or update a key in the secrets.env file.
@@ -8348,6 +8897,9 @@ fn cron_job_to_schedule_view(
};
let meta = kernel.cron_scheduler.get_meta(job.id);
let last_status = meta.as_ref().and_then(|m| m.last_status.clone());
// Serialize delivery_targets so dashboard chips/editor round-trip cleanly.
let delivery_targets = serde_json::to_value(&job.delivery_targets)
.unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
serde_json::json!({
"id": job.id.to_string(),
@@ -8360,6 +8912,7 @@ fn cron_job_to_schedule_view(
"last_run": job.last_run.map(|t| t.to_rfc3339()),
"next_run": job.next_run.map(|t| t.to_rfc3339()),
"last_status": last_status,
"delivery_targets": delivery_targets,
})
}
@@ -8449,7 +9002,38 @@ pub async fn create_schedule(
message.clone()
};
let job_json = serde_json::json!({
// Accept multi-destination delivery targets. Validate each entry matches
// the `CronDeliveryTarget` shape up front so we return a 400 rather than
// silently dropping targets or failing later in the kernel.
let delivery_targets_raw = req
.get("delivery_targets")
.cloned()
.unwrap_or(serde_json::Value::Null);
if !delivery_targets_raw.is_null() && !delivery_targets_raw.is_array() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "delivery_targets must be an array"
})),
);
}
if let Some(arr) = delivery_targets_raw.as_array() {
for (idx, t) in arr.iter().enumerate() {
if let Err(e) = serde_json::from_value::<
openfang_types::scheduler::CronDeliveryTarget,
>(t.clone())
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("delivery_targets[{idx}] invalid: {e}")
})),
);
}
}
}
let mut job_json = serde_json::json!({
"name": sanitize_schedule_job_name(&name_raw),
"schedule": { "kind": "cron", "expr": cron, "tz": null },
"action": {
@@ -8461,6 +9045,9 @@ pub async fn create_schedule(
"delivery": { "kind": "none" },
"one_shot": false,
});
if let Some(arr) = delivery_targets_raw.as_array() {
job_json["delivery_targets"] = serde_json::Value::Array(arr.clone());
}
match state
.kernel
@@ -8550,18 +9137,64 @@ pub async fn update_schedule(
}
let _ = state.kernel.cron_scheduler.persist();
}
// Apply a full replacement of delivery_targets when supplied. Validation
// is done up front via serde so a bad entry produces a 400 rather than
// silently replacing the list with the valid subset.
if let Some(raw_targets) = req.get("delivery_targets") {
if !raw_targets.is_array() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "delivery_targets must be an array"})),
);
}
let arr = raw_targets.as_array().unwrap();
let mut parsed: Vec<openfang_types::scheduler::CronDeliveryTarget> =
Vec::with_capacity(arr.len());
for (idx, t) in arr.iter().enumerate() {
match serde_json::from_value::<openfang_types::scheduler::CronDeliveryTarget>(
t.clone(),
) {
Ok(dt) => parsed.push(dt),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("delivery_targets[{idx}] invalid: {e}")
})),
);
}
}
}
if let Err(e) = state
.kernel
.cron_scheduler
.set_delivery_targets(cj_id, parsed)
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
let _ = state.kernel.cron_scheduler.persist();
}
if req.get("name").is_some()
|| req.get("cron").is_some()
|| req.get("agent_id").is_some()
|| req.get("message").is_some()
{
note = Some("Only 'enabled' is mutable; delete and recreate to change other fields.");
note = Some("Only 'enabled' and 'delivery_targets' are mutable; delete and recreate to change other fields.");
}
let mut body = serde_json::json!({"status": "updated", "schedule_id": id});
if let Some(n) = note {
body["note"] = serde_json::Value::String(n.to_string());
}
// Echo the new view so callers can confirm without a second GET.
if let Some(job) = state.kernel.cron_scheduler.get_job(cj_id) {
body["schedule"] = cron_job_to_schedule_view(&state.kernel, &job);
}
(StatusCode::OK, Json(body))
}
@@ -8648,6 +9281,53 @@ pub async fn run_schedule(
}
}
/// GET /api/schedules/{id}/delivery-log — Return the last N per-target
/// delivery results for a schedule.
///
/// Delivery results are not yet persisted across runs, so this endpoint
/// returns an empty list for jobs that exist. A future change can back this
/// with a ring buffer populated by `cron_fan_out_targets`. The endpoint is
/// wired up now so the dashboard can call it without a placeholder.
pub async fn schedule_delivery_log(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let uuid = match uuid::Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid schedule id"})),
);
}
};
let cj_id = openfang_types::scheduler::CronJobId(uuid);
let job = match state.kernel.cron_scheduler.get_job(cj_id) {
Some(j) => j,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Schedule not found"})),
);
}
};
// Delivery history is not yet persisted; surface the target list with
// empty result arrays so the UI has stable shape to render.
let targets: Vec<serde_json::Value> = job
.delivery_targets
.iter()
.map(|t| serde_json::to_value(t).unwrap_or_else(|_| serde_json::Value::Null))
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"schedule_id": id,
"targets": targets,
"entries": [],
})),
)
}
/// Sanitize a user-supplied schedule name into a valid `CronJob.name`.
/// Matches the kernel's own sanitizer used by the migration path.
fn sanitize_schedule_job_name(raw: &str) -> String {
@@ -11636,3 +12316,131 @@ mod channel_config_tests {
);
}
}
#[cfg(test)]
mod skill_config_tests {
//! Unit tests for the `/api/skills/{id}/config` endpoints.
//!
//! These exercise the on-disk TOML writer and the in-memory snapshot
//! builder directly. Live end-to-end coverage (real HTTP, real kernel)
//! lives in `tests/skill_config_api_test.rs`.
use super::*;
fn sample_values() -> std::collections::HashMap<String, String> {
let mut m = std::collections::HashMap::new();
m.insert("github_token".to_string(), "ghp_test1234".to_string());
m.insert("default_branch".to_string(), "develop".to_string());
m
}
#[test]
fn upsert_creates_skills_section_from_blank_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
upsert_skill_config(&path, "demo-skill", &sample_values()).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[skills.demo-skill]"), "text was: {text}");
assert!(text.contains("github_token = \"ghp_test1234\""));
assert!(text.contains("default_branch = \"develop\""));
}
#[test]
fn upsert_preserves_other_root_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"log_level = \"debug\"\napi_listen = \"127.0.0.1:4200\"\n",
)
.unwrap();
upsert_skill_config(&path, "demo-skill", &sample_values()).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("log_level"), "log_level lost: {text}");
assert!(text.contains("api_listen"), "api_listen lost: {text}");
assert!(text.contains("[skills.demo-skill]"));
}
#[test]
fn upsert_replaces_existing_section_wholesale() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[skills.demo-skill]\nold_key = \"old_value\"\nshared = \"before\"\n",
)
.unwrap();
let mut new_values = std::collections::HashMap::new();
new_values.insert("shared".to_string(), "after".to_string());
upsert_skill_config(&path, "demo-skill", &new_values).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("old_key"), "old_key not dropped: {text}");
assert!(text.contains("shared = \"after\""));
}
#[test]
fn remove_drops_single_var_but_keeps_table_when_nonempty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
upsert_skill_config(&path, "demo-skill", &sample_values()).unwrap();
remove_skill_config_var(&path, "demo-skill", "github_token").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("github_token"));
assert!(text.contains("default_branch"));
assert!(text.contains("[skills.demo-skill]"));
}
#[test]
fn remove_last_var_removes_whole_skill_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut single = std::collections::HashMap::new();
single.insert("only".to_string(), "x".to_string());
upsert_skill_config(&path, "demo-skill", &single).unwrap();
remove_skill_config_var(&path, "demo-skill", "only").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(
!text.contains("demo-skill"),
"empty section not cleaned up: {text}"
);
}
#[test]
fn remove_missing_file_is_silent_ok() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
// File does not exist — must not error.
remove_skill_config_var(&path, "demo-skill", "github_token").unwrap();
}
#[test]
fn atomic_write_produces_valid_toml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut doc = toml::map::Map::new();
let mut skills = toml::map::Map::new();
let mut inner = toml::map::Map::new();
inner.insert(
"github_token".to_string(),
toml::Value::String("ghp_xyz".to_string()),
);
skills.insert("demo".to_string(), toml::Value::Table(inner));
doc.insert("skills".to_string(), toml::Value::Table(skills));
let doc = toml::Value::Table(doc);
atomic_write_toml(&path, &doc).unwrap();
let back: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(back, doc);
}
}

View File

@@ -343,6 +343,10 @@ pub async fn build_router(
"/api/schedules/{id}/run",
axum::routing::post(routes::run_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
// Workflow endpoints
.route(
"/api/workflows",
@@ -376,6 +380,14 @@ pub async fn build_router(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),

View File

@@ -1720,6 +1720,7 @@
<th>Schedule</th>
<th>Agent</th>
<th>Status</th>
<th>Delivery</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Actions</th>
@@ -1731,6 +1732,34 @@
<td>
<span class="font-bold" x-text="job.name || job.description || '(unnamed)'"></span>
<div class="text-xs text-dim" x-show="job.message" x-text="(job.message || '').substring(0, 60) + ((job.message || '').length > 60 ? '...' : '')" :title="job.message"></div>
<div x-show="expandedJobId === job.id" style="margin-top:8px;padding:8px;background:rgba(148,163,184,0.06);border-left:3px solid var(--accent);border-radius:3px">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">Delivery Log</div>
<div x-show="deliveryLogLoading" class="text-xs text-dim">Loading delivery log...</div>
<div x-show="!deliveryLogLoading && deliveryLogError" class="text-xs" style="color:var(--error)" x-text="deliveryLogError"></div>
<div x-show="!deliveryLogLoading && !deliveryLogError">
<div class="text-xs text-dim" style="margin-bottom:4px">Configured targets (<span x-text="(deliveryLog.targets || []).length"></span>):</div>
<div x-show="!(deliveryLog.targets || []).length" class="text-xs text-dim">No fan-out targets configured.</div>
<template x-for="(t, ti) in (deliveryLog.targets || [])" :key="ti">
<div class="text-xs" style="padding:2px 0">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<span class="text-dim" style="margin-left:6px" x-text="targetSummary(t)"></span>
</div>
</template>
<div class="text-xs text-dim" style="margin-top:8px">Recent deliveries (<span x-text="(deliveryLog.entries || []).length"></span>):</div>
<div x-show="!(deliveryLog.entries || []).length" class="text-xs text-dim">
Delivery history will appear here after the next run. Per-run results are not yet
persisted — the fan-out engine emits tracing events you can view under
<a href="#logs" style="color:var(--accent)">Logs</a>.
</div>
<template x-for="(e, ei) in (deliveryLog.entries || [])" :key="ei">
<div class="text-xs" style="padding:2px 0">
<span class="badge" :class="e.success ? 'badge-success' : 'badge-error'" x-text="e.success ? 'ok' : 'err'"></span>
<span style="margin-left:6px" x-text="e.target"></span>
<span class="text-dim" style="margin-left:6px" x-show="e.error" x-text="e.error"></span>
</div>
</template>
</div>
</div>
</td>
<td>
<code style="font-size:11px;color:var(--accent)" x-text="job.cron"></code>
@@ -1740,15 +1769,25 @@
<td>
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
</td>
<td>
<div class="flex gap-1" style="flex-wrap:wrap;max-width:240px" x-show="(job.delivery_targets || []).length">
<template x-for="(t, ti) in (job.delivery_targets || [])" :key="ti">
<span class="badge" :class="targetChipClass(t)" :title="targetSummary(t)" x-text="targetChipLabel(t)"></span>
</template>
</div>
<span class="text-xs text-dim" x-show="!(job.delivery_targets || []).length">(none)</span>
</td>
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
<td>
<div class="flex gap-1">
<div class="flex gap-1" style="flex-wrap:wrap">
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
<span x-show="runningJobId !== job.id">Run</span>
<span x-show="runningJobId === job.id">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="toggleJob(job)" x-text="job.enabled ? 'Pause' : 'Enable'"></button>
<button class="btn btn-ghost btn-sm" @click="toggleExpand(job)" x-text="expandedJobId === job.id ? 'Hide' : 'Details'"></button>
<button class="btn btn-ghost btn-sm" @click="startEditTargets(job)">Targets</button>
<button class="btn btn-danger btn-sm" @click="deleteJob(job)">Del</button>
</div>
</td>
@@ -1820,6 +1859,104 @@
</label>
</div>
<div class="form-group">
<div class="flex items-center justify-between" style="margin-bottom:6px">
<label style="margin:0">Delivery Targets</label>
<button class="btn btn-ghost btn-sm" type="button" @click="openTargetPicker()">+ Add target</button>
</div>
<div class="text-xs text-dim mt-1" x-show="!(newJob.delivery_targets || []).length">
The job output is delivered to the agent's last channel by default. Add one or more
fan-out destinations — channels, webhooks, local files, or email — to copy the output
to extra destinations each run.
</div>
<div x-show="(newJob.delivery_targets || []).length" style="margin-top:6px">
<template x-for="(t, ti) in (newJob.delivery_targets || [])" :key="ti">
<div class="card" style="padding:8px;margin-bottom:6px">
<div class="flex items-center justify-between">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<button class="btn btn-ghost btn-sm" type="button" @click="removeTarget(ti)">Remove</button>
</div>
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
</div>
</template>
</div>
</div>
<!-- Target picker (shared by create + edit flows) -->
<div class="card" x-show="showTargetPicker && !editingTargetsJobId" style="border-left:3px solid var(--accent);margin-top:8px">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
<div class="form-group">
<label>Type</label>
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
<option value="channel">Channel</option>
<option value="webhook">Webhook</option>
<option value="local_file">Local File</option>
<option value="email">Email</option>
</select>
</div>
<template x-if="draftTarget && draftTarget.type === 'channel'">
<div>
<div class="form-group">
<label>Channel type</label>
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
<option value="">-- pick --</option>
<template x-for="c in channelTypes" :key="c">
<option :value="c" x-text="c"></option>
</template>
</select>
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
</div>
<div class="form-group">
<label>Recipient</label>
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'webhook'">
<div>
<div class="form-group">
<label>URL</label>
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
</div>
<div class="form-group">
<label>Authorization header (optional)</label>
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'local_file'">
<div>
<div class="form-group">
<label>Path</label>
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
</div>
<div class="form-group">
<label class="flex items-center gap-2">
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
</label>
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'email'">
<div>
<div class="form-group">
<label>To</label>
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
</div>
<div class="form-group">
<label>Subject template (optional)</label>
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
</div>
</div>
</template>
<div class="flex gap-2" style="margin-top:6px">
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTarget()">Add</button>
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
</div>
</div>
<button class="btn btn-primary btn-block mt-4" @click="createJob()" :disabled="creating">
<span x-show="!creating">Create Schedule</span>
<span x-show="creating">Creating...</span>
@@ -1827,6 +1964,116 @@
</div>
</div>
</template>
<!-- Edit Targets Modal -->
<template x-if="editingTargetsJobId">
<div class="modal-overlay" @click.self="cancelEditTargets()" @keydown.escape.window="cancelEditTargets()">
<div class="modal">
<div class="modal-header">
<h3>Edit Delivery Targets</h3>
<button class="modal-close" @click="cancelEditTargets()">x</button>
</div>
<div class="form-group">
<div class="flex items-center justify-between" style="margin-bottom:6px">
<div class="text-xs text-dim">
Targets are replaced on save. Leave empty to remove all fan-out destinations.
</div>
<button class="btn btn-ghost btn-sm" type="button" @click="addEditTarget()">+ Add target</button>
</div>
<div x-show="!editingTargets.length" class="text-xs text-dim">No targets configured.</div>
<template x-for="(t, ti) in editingTargets" :key="ti">
<div class="card" style="padding:8px;margin-bottom:6px">
<div class="flex items-center justify-between">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<button class="btn btn-ghost btn-sm" type="button" @click="removeEditTarget(ti)">Remove</button>
</div>
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
</div>
</template>
</div>
<div class="card" x-show="showTargetPicker" style="border-left:3px solid var(--accent)">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
<div class="form-group">
<label>Type</label>
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
<option value="channel">Channel</option>
<option value="webhook">Webhook</option>
<option value="local_file">Local File</option>
<option value="email">Email</option>
</select>
</div>
<template x-if="draftTarget && draftTarget.type === 'channel'">
<div>
<div class="form-group">
<label>Channel type</label>
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
<option value="">-- pick --</option>
<template x-for="c in channelTypes" :key="c">
<option :value="c" x-text="c"></option>
</template>
</select>
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
</div>
<div class="form-group">
<label>Recipient</label>
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'webhook'">
<div>
<div class="form-group">
<label>URL</label>
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
</div>
<div class="form-group">
<label>Authorization header (optional)</label>
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'local_file'">
<div>
<div class="form-group">
<label>Path</label>
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
</div>
<div class="form-group">
<label class="flex items-center gap-2">
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
</label>
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'email'">
<div>
<div class="form-group">
<label>To</label>
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
</div>
<div class="form-group">
<label>Subject template (optional)</label>
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
</div>
</div>
</template>
<div class="flex gap-2" style="margin-top:6px">
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTargetToEdit()">Add</button>
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary" @click="saveEditTargets()" :disabled="savingTargets">
<span x-show="!savingTargets">Save</span>
<span x-show="savingTargets">Saving...</span>
</button>
<button class="btn btn-ghost" @click="cancelEditTargets()">Cancel</button>
</div>
</div>
</div>
</template>
</div>
<!-- ── TAB: Event Triggers ── -->
@@ -2234,8 +2481,12 @@
<span class="text-xs text-dim" x-text="skill.tools_count + ' tool(s)'"></span>
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
<span class="text-xs text-dim" x-show="skill.has_prompt_context">(prompt context)</span>
<span class="text-xs text-dim" x-show="skill.config_declared_count > 0" x-text="skill.config_declared_count + ' config var(s)'"></span>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" x-show="skill.config_declared_count > 0" @click="openSkillConfig(skill)" title="Configure skill variables">&#9881; Configure</button>
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
</div>
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
</div>
</div>
</template>
@@ -2509,6 +2760,80 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Skill Config Modal (SKILL.md frontmatter `config:` variables) -->
<template x-if="configSkill">
<div class="modal-overlay" @click.self="closeSkillConfig()" @keydown.escape.window="closeSkillConfig()">
<div class="modal" style="max-width:640px">
<div class="modal-header">
<div>
<h3>Configure: <span x-text="configSkill.name"></span></h3>
<div class="text-xs text-dim mt-1" x-text="configSkill.description"></div>
</div>
<button class="modal-close" @click="closeSkillConfig()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div x-show="configLoading" class="loading-state" style="padding:20px 0">
<div class="spinner"></div><span>Loading configuration&hellip;</span>
</div>
<div x-show="!configLoading && configError" class="error-state">
<span class="error-icon">!</span>
<p x-text="configError"></p>
</div>
<div x-show="!configLoading && !configError">
<div x-show="configDeclaredNames.length === 0" class="empty-state" style="padding:16px 0">
<p class="text-dim">This skill does not declare any runtime config variables.</p>
</div>
<template x-for="name in configDeclaredNames" :key="name">
<div class="form-group" style="margin-bottom:16px">
<label style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<code x-text="name" style="font-size:0.8rem"></code>
<span x-show="configDeclared[name] && configDeclared[name].required" class="badge badge-danger" style="font-size:0.6rem">required</span>
<span class="badge" :class="sourceBadgeClass(configResolved[name] && configResolved[name].source)" style="font-size:0.6rem">
<template x-if="configResolved[name] && configResolved[name].source === 'user'"><span>user override</span></template>
<template x-if="configResolved[name] && configResolved[name].source === 'env'">
<span>env<template x-if="configDeclared[name] && configDeclared[name].env"><span>:<span x-text="configDeclared[name].env"></span></span></template></span>
</template>
<template x-if="configResolved[name] && configResolved[name].source === 'default'"><span>default</span></template>
<template x-if="!configResolved[name] || configResolved[name].source === 'unresolved'"><span>unresolved &#9888;</span></template>
</span>
</label>
<div class="text-xs text-dim" x-show="configDeclared[name] && configDeclared[name].description" x-text="configDeclared[name] ? configDeclared[name].description : ''" style="margin:2px 0 6px 0"></div>
<div class="flex gap-2 items-center" style="position:relative">
<input
style="flex:1"
:type="(configResolved[name] && configResolved[name].is_secret && !configRevealed[name]) ? 'password' : 'text'"
:placeholder="(configResolved[name] && configResolved[name].source !== 'user' && configResolved[name].value != null) ? ('Currently: ' + configResolved[name].value) : ((configDeclared[name] && configDeclared[name].default) ? ('default: ' + configDeclared[name].default) : 'Enter value')"
x-model="configDraft[name]"
:class="{ 'input-error': configRowInvalid(name) }">
<button type="button" class="btn btn-ghost btn-sm" x-show="configResolved[name] && configResolved[name].is_secret" @click="toggleReveal(name)" :title="configRevealed[name] ? 'Hide' : 'Reveal'">
<svg x-show="!configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg x-show="configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-10-8-10-8a19.77 19.77 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 8 10 8a19.77 19.77 0 0 1-2.16 3.19M1 1l22 22"/></svg>
</button>
</div>
<div class="text-xs" style="margin-top:4px;display:flex;justify-content:space-between;align-items:center">
<div class="text-dim" style="font-style:italic" x-show="configResolved[name] && configResolved[name].source === 'user'">Leaving blank and saving keeps existing override. Use Reset to remove it.</div>
<a href="#" class="text-dim" style="font-size:0.7rem" x-show="configResolved[name] && configResolved[name].source === 'user'" @click.prevent="resetSkillConfigVar(name)">Reset to env/default</a>
</div>
</div>
</template>
<div class="text-xs text-dim" style="margin-top:12px">
Values are stored in <code>~/.openfang/config.toml</code> under <code>[skills.<span x-text="configSkill.name"></span>]</code>. Secrets are redacted in this view.
</div>
<div class="flex justify-end gap-2" style="margin-top:16px">
<button class="btn btn-ghost" @click="closeSkillConfig()" :disabled="configSaving">Cancel</button>
<button class="btn btn-primary" @click="saveSkillConfig()" :disabled="configSaving || hasInvalidConfig()" x-text="configSaving ? 'Saving...' : 'Save'"></button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>

View File

@@ -26,13 +26,33 @@ function schedulerPage() {
cron: '',
agent_id: '',
message: '',
enabled: true
enabled: true,
delivery_targets: []
},
creating: false,
// -- Run Now state --
runningJobId: '',
// -- Delivery targets picker (create modal) --
showTargetPicker: false,
pickerType: 'channel',
draftTarget: null,
// -- Expanded job / delivery log state --
expandedJobId: '',
deliveryLog: { targets: [], entries: [] },
deliveryLogLoading: false,
deliveryLogError: '',
// -- Edit targets state (per-existing-job) --
editingTargetsJobId: '',
editingTargets: [],
savingTargets: false,
// -- Available channel types (populated from /api/channels) --
channelTypes: [],
// Cron presets
cronPresets: [
{ label: 'Every minute', cron: '* * * * *' },
@@ -55,12 +75,32 @@ function schedulerPage() {
this.loadError = '';
try {
await this.loadJobs();
// Channels are optional — failure is non-fatal for the scheduler page.
this.loadChannelTypes();
} catch(e) {
this.loadError = e.message || 'Could not load scheduler data.';
}
this.loading = false;
},
async loadChannelTypes() {
try {
var data = await OpenFangAPI.get('/api/channels');
// /api/channels returns an array of channel descriptors; pull names.
var list = Array.isArray(data) ? data : (data && data.channels) || [];
var names = [];
for (var i = 0; i < list.length; i++) {
var ch = list[i];
var name = ch && (ch.name || ch.display_name || ch.channel_type);
if (name && names.indexOf(name) === -1) names.push(name);
}
this.channelTypes = names;
} catch(e) {
// Fall through silently — the form uses a plain input as fallback.
this.channelTypes = [];
}
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
@@ -82,6 +122,7 @@ function schedulerPage() {
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
created_at: j.created_at
};
});
@@ -162,9 +203,12 @@ function schedulerPage() {
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
};
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
}
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
OpenFangToast.success('Schedule "' + jobName + '" created');
await this.loadJobs();
} catch(e) {
@@ -216,6 +260,210 @@ function schedulerPage() {
this.runningJobId = '';
},
// ── Delivery target editing (create modal) ──
openTargetPicker() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
cancelTargetPicker() {
this.showTargetPicker = false;
this.draftTarget = null;
},
onPickerTypeChange() {
this.draftTarget = this.blankTarget(this.pickerType);
},
blankTarget(type) {
if (type === 'channel') {
return { type: 'channel', channel_type: '', recipient: '' };
}
if (type === 'webhook') {
return { type: 'webhook', url: '', auth_header: '' };
}
if (type === 'local_file') {
return { type: 'local_file', path: '', append: false };
}
if (type === 'email') {
return { type: 'email', to: '', subject_template: '' };
}
return null;
},
addDraftTarget() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeTarget(idx) {
if (!Array.isArray(this.newJob.delivery_targets)) return;
this.newJob.delivery_targets.splice(idx, 1);
},
validateTarget(t) {
if (!t || !t.type) return 'Pick a target type';
if (t.type === 'channel') {
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
} else if (t.type === 'webhook') {
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
return 'Webhook URL must start with http:// or https://';
}
} else if (t.type === 'local_file') {
if (!t.path || !t.path.trim()) return 'File path is required';
} else if (t.type === 'email') {
if (!t.to || !t.to.trim()) return 'Recipient email is required';
}
return null;
},
// Strip empty-string optional fields so serde accepts the payload cleanly.
sanitizeTarget(t) {
if (!t) return null;
var out = { type: t.type };
if (t.type === 'channel') {
out.channel_type = (t.channel_type || '').trim();
out.recipient = (t.recipient || '').trim();
} else if (t.type === 'webhook') {
out.url = (t.url || '').trim();
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
} else if (t.type === 'local_file') {
out.path = (t.path || '').trim();
out.append = !!t.append;
} else if (t.type === 'email') {
out.to = (t.to || '').trim();
if (t.subject_template && t.subject_template.trim()) {
out.subject_template = t.subject_template.trim();
}
}
return out;
},
// ── Chip rendering helpers ──
targetChipLabel(t) {
if (!t || !t.type) return '?';
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
if (t.type === 'webhook') return 'WEBHOOK';
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
return t.type.toUpperCase();
},
targetChipClass(t) {
if (!t || !t.type) return 'badge-dim';
if (t.type === 'channel') return 'badge-info';
if (t.type === 'webhook') return 'badge-created';
if (t.type === 'local_file') return 'badge-muted';
if (t.type === 'email') return 'badge-warn';
return 'badge-dim';
},
targetSummary(t) {
if (!t) return '';
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
if (t.type === 'webhook') return t.url || '(no url)';
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
if (t.type === 'email') {
var base = t.to || '';
if (t.subject_template) base += ' · subject: ' + t.subject_template;
return base;
}
return JSON.stringify(t);
},
// ── Expand row / delivery log ──
async toggleExpand(job) {
if (this.expandedJobId === job.id) {
this.expandedJobId = '';
return;
}
this.expandedJobId = job.id;
this.deliveryLog = { targets: [], entries: [] };
this.deliveryLogError = '';
this.deliveryLogLoading = true;
try {
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
this.deliveryLog = {
targets: Array.isArray(data.targets) ? data.targets : [],
entries: Array.isArray(data.entries) ? data.entries : []
};
} catch(e) {
this.deliveryLogError = e.message || 'Could not load delivery log.';
}
this.deliveryLogLoading = false;
},
// ── Edit targets on existing job ──
startEditTargets(job) {
this.editingTargetsJobId = job.id;
// Clone so cancel doesn't mutate the loaded list.
this.editingTargets = (job.delivery_targets || []).map(function(t) {
return JSON.parse(JSON.stringify(t));
});
this.pickerType = 'channel';
this.draftTarget = null;
this.showTargetPicker = false;
},
cancelEditTargets() {
this.editingTargetsJobId = '';
this.editingTargets = [];
this.draftTarget = null;
this.showTargetPicker = false;
},
addEditTarget() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
addDraftTargetToEdit() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeEditTarget(idx) {
this.editingTargets.splice(idx, 1);
},
async saveEditTargets() {
if (!this.editingTargetsJobId) return;
this.savingTargets = true;
try {
var clean = this.editingTargets.map(this.sanitizeTarget);
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
delivery_targets: clean
});
OpenFangToast.success('Delivery targets updated');
this.cancelEditTargets();
await this.loadJobs();
} catch(e) {
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
}
this.savingTargets = false;
},
// ── Trigger helpers ──
triggerType(pattern) {
@@ -376,6 +624,12 @@ function schedulerPage() {
} catch(e) { return 'never'; }
},
truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.substring(0, n - 1) + '…';
},
jobCount() {
var enabled = 0;
for (var i = 0; i < this.jobs.length; i++) {

View File

@@ -123,6 +123,22 @@ async fn start_test_server_with_provider(
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.route("/api/commands", axum::routing::get(routes::list_commands))
.route(
"/api/schedules",
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
)
.route(
"/api/schedules/{id}",
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
.route(
"/api/cron/jobs",
axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
@@ -1142,3 +1158,355 @@ async fn test_commands_invalid_surface_400() {
let err = body["error"].as_str().unwrap_or_default();
assert!(err.contains("bogus"), "error should mention the bad value: {err}");
}
// ---------------------------------------------------------------------------
// Schedule delivery_targets round-trip tests
// ---------------------------------------------------------------------------
//
// These exercise the `/api/schedules` and `/api/cron/jobs` endpoints to
// confirm `CronDeliveryTarget` variants round-trip cleanly through create /
// list / update / delivery-log, and that bad input is rejected at the API
// layer rather than silently dropped.
async fn spawn_test_agent(server: &TestServer) -> String {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
body["agent_id"].as_str().unwrap().to_string()
}
/// POST /api/schedules with all four `CronDeliveryTarget` variants should
/// store them and return them on GET /api/schedules.
#[tokio::test]
async fn test_schedules_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let delivery_targets = serde_json::json!([
{ "type": "channel", "channel_type": "telegram", "recipient": "chat_12345" },
{ "type": "webhook", "url": "https://example.com/hook", "auth_header": "Bearer abc" },
{ "type": "local_file", "path": "/tmp/openfang-test.log", "append": true },
{ "type": "email", "to": "alice@example.com", "subject_template": "Cron: {job}" },
]);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "multi-destination-test",
"cron": "0 9 * * 1-5",
"agent_id": agent_id,
"message": "Generate the daily brief.",
"enabled": true,
"delivery_targets": delivery_targets,
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"].as_str().expect("created schedule id").to_string();
let got = body["delivery_targets"]
.as_array()
.expect("response must include delivery_targets");
assert_eq!(got.len(), 4, "all four targets should round-trip");
assert_eq!(got[0]["type"], "channel");
assert_eq!(got[0]["channel_type"], "telegram");
assert_eq!(got[0]["recipient"], "chat_12345");
assert_eq!(got[1]["type"], "webhook");
assert_eq!(got[1]["url"], "https://example.com/hook");
assert_eq!(got[1]["auth_header"], "Bearer abc");
assert_eq!(got[2]["type"], "local_file");
assert_eq!(got[2]["append"], true);
assert_eq!(got[3]["type"], "email");
assert_eq!(got[3]["subject_template"], "Cron: {job}");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let schedules = body["schedules"].as_array().unwrap();
let created = schedules
.iter()
.find(|s| s["id"] == sched_id)
.expect("created schedule must appear in list");
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 4);
assert_eq!(listed[0]["channel_type"], "telegram");
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// PUT /api/schedules/{id} with `delivery_targets` should fully replace the
/// target list.
#[tokio::test]
async fn test_schedules_delivery_targets_update() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "update-target-test",
"cron": "*/15 * * * *",
"agent_id": agent_id,
"message": "hi",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"].as_str().unwrap().to_string();
assert_eq!(
body["delivery_targets"].as_array().map(|a| a.len()),
Some(0)
);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({
"delivery_targets": [
{ "type": "webhook", "url": "https://new.example.com/hook" },
{ "type": "local_file", "path": "/tmp/new.log" },
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "updated");
let echoed = &body["schedule"]["delivery_targets"];
let arr = echoed.as_array().expect("schedule.delivery_targets must be array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "webhook");
assert_eq!(arr[1]["type"], "local_file");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 2);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({"delivery_targets": []}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 0);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// Malformed `delivery_targets` should return 400, not silently succeed.
#[tokio::test]
async fn test_schedules_rejects_bad_delivery_target() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-target-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": [
{ "type": "channel" /* missing channel_type + recipient */ }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("delivery_targets"),
"error should mention delivery_targets, got: {err}"
);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-array-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": "not-an-array",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
/// GET /api/schedules/{id}/delivery-log returns the configured targets and an
/// empty entries array for a known schedule, and 404 for a random UUID.
#[tokio::test]
async fn test_schedules_delivery_log_endpoint() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "log-test",
"cron": "0 * * * *",
"agent_id": agent_id,
"message": "x",
"delivery_targets": [
{ "type": "webhook", "url": "https://example.com/h" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let sched_id = resp
.json::<serde_json::Value>()
.await
.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, sched_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["schedule_id"], sched_id);
let targets = body["targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["type"], "webhook");
let entries = body["entries"].as_array().expect("entries array");
assert!(
entries.is_empty(),
"delivery history is not persisted yet — entries must be empty"
);
let random = "550e8400-e29b-41d4-a716-446655440000";
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, random
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let resp = client
.get(format!(
"{}/api/schedules/not-a-uuid/delivery-log",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// POST /api/cron/jobs with `delivery_targets` should persist them and they
/// should appear on the subsequent GET.
#[tokio::test]
async fn test_cron_jobs_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/cron/jobs", server.base_url))
.json(&serde_json::json!({
"agent_id": agent_id,
"name": "cron-fanout",
"schedule": { "kind": "cron", "expr": "*/20 * * * *" },
"action": { "kind": "agent_turn", "message": "pulse" },
"delivery": { "kind": "none" },
"delivery_targets": [
{ "type": "local_file", "path": "/tmp/pulse.log", "append": true },
{ "type": "webhook", "url": "http://example.com/pulse" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let resp = client
.get(format!(
"{}/api/cron/jobs?agent_id={}",
server.base_url, agent_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let jobs = body["jobs"].as_array().unwrap();
let job = jobs
.iter()
.find(|j| j["name"] == "cron-fanout")
.expect("created job must be listed");
let targets = job["delivery_targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 2);
assert_eq!(targets[0]["type"], "local_file");
assert_eq!(targets[0]["path"], "/tmp/pulse.log");
assert_eq!(targets[0]["append"], true);
assert_eq!(targets[1]["type"], "webhook");
assert_eq!(targets[1]["url"], "http://example.com/pulse");
}

View File

@@ -198,6 +198,25 @@ impl CronScheduler {
}
}
/// Replace the multi-destination delivery targets on an existing job.
///
/// The schedule, action, and primary `delivery` field are left untouched;
/// only the `delivery_targets` fan-out list is swapped in. Call
/// [`persist`] afterwards to write the change to disk.
pub fn set_delivery_targets(
&self,
id: CronJobId,
targets: Vec<openfang_types::scheduler::CronDeliveryTarget>,
) -> OpenFangResult<()> {
match self.jobs.get_mut(&id) {
Some(mut meta) => {
meta.job.delivery_targets = targets;
Ok(())
}
None => Err(OpenFangError::Internal(format!("Cron job {id} not found"))),
}
}
// -- Queries ------------------------------------------------------------
/// Get a single job by ID.

View File

@@ -612,7 +612,7 @@ mod tests {
fn autocomplete_prefix_matches_canonical_name() {
let matches = autocomplete("ne", Surfaces::CHANNEL);
assert!(
matches.iter().any(|m| *m == "new"),
matches.contains(&"new"),
"autocomplete(`ne`) must include `new`, got {matches:?}"
);
}
@@ -621,7 +621,7 @@ mod tests {
fn autocomplete_prefix_matches_alias() {
let matches = autocomplete("res", Surfaces::CHANNEL);
assert!(
matches.iter().any(|m| *m == "reset"),
matches.contains(&"reset"),
"autocomplete(`res`) must include `reset` (alias of new), got {matches:?}"
);
}
@@ -640,9 +640,9 @@ mod tests {
fn autocomplete_respects_surface_filter() {
// `kill` is CLI-only; it must not appear in CHANNEL autocomplete.
let channel_matches = autocomplete("ki", Surfaces::CHANNEL);
assert!(!channel_matches.iter().any(|m| *m == "kill"));
assert!(!channel_matches.contains(&"kill"));
let cli_matches = autocomplete("ki", Surfaces::CLI);
assert!(cli_matches.iter().any(|m| *m == "kill"));
assert!(cli_matches.contains(&"kill"));
}
#[test]