mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-04-25 17:25:11 +02:00
cron delivery ui
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">⚙ 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…</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 ⚠</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>
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user