Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "nomifun-cron"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-conversation.workspace = true
|
||||
nomifun-ai-agent.workspace = true
|
||||
nomifun-auth.workspace = true
|
||||
axum.workspace = true
|
||||
cron.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
dashmap.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tempfile.workspace = true
|
||||
# Enable the `AgentInstance::Mock` variant so tests can build fake agents
|
||||
# through the trait-object escape hatch without spawning real CLI processes.
|
||||
nomifun-ai-agent = { workspace = true, features = ["test-support"] }
|
||||
@@ -0,0 +1,169 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{ConversationArtifactResponse, WebSocketMessage};
|
||||
use nomifun_db::ConversationArtifactRow;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::CronError;
|
||||
use crate::types::CronJob;
|
||||
|
||||
/// Parse a string-keyed conversation id into the integer DB key. Cron carries
|
||||
/// conversation ids as `String` through the agent path (Option A); artifact
|
||||
/// rows are keyed by `i64`, so we convert at this boundary. An unparseable id
|
||||
/// degrades to `0`, which matches no conversation row (the upsert/broadcast is
|
||||
/// then a harmless no-op rather than a panic).
|
||||
fn parse_conversation_id(conversation_id: &str) -> i64 {
|
||||
conversation_id.parse::<i64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cron_trigger_artifact(
|
||||
conversation_id: &str,
|
||||
job: &CronJob,
|
||||
created_at: i64,
|
||||
) -> ConversationArtifactRow {
|
||||
let payload = json!({
|
||||
"cron_job_id": job.id,
|
||||
"cron_job_name": job.name,
|
||||
"triggered_at": created_at,
|
||||
});
|
||||
|
||||
ConversationArtifactRow {
|
||||
// `id` is assigned by SQLite on insert; `upsert_artifact` ignores this
|
||||
// placeholder. `cron_trigger` rows are always fresh inserts (one per
|
||||
// trigger), no longer deduplicated by a composite string id.
|
||||
id: 0,
|
||||
conversation_id: parse_conversation_id(conversation_id),
|
||||
cron_job_id: Some(job.id.clone()),
|
||||
kind: "cron_trigger".into(),
|
||||
status: "active".into(),
|
||||
payload: payload.to_string(),
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_suggest_artifact(
|
||||
conversation_id: &str,
|
||||
job_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
skill_content: &str,
|
||||
now: i64,
|
||||
) -> ConversationArtifactRow {
|
||||
let payload = json!({
|
||||
"cron_job_id": job_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"skillContent": skill_content,
|
||||
});
|
||||
|
||||
ConversationArtifactRow {
|
||||
// `id` is assigned by SQLite; idempotency for `skill_suggest` is the
|
||||
// partial-unique `(conversation_id, cron_job_id)` constraint that
|
||||
// `upsert_artifact` targets, not this placeholder.
|
||||
id: 0,
|
||||
conversation_id: parse_conversation_id(conversation_id),
|
||||
cron_job_id: Some(job_id.to_owned()),
|
||||
kind: "skill_suggest".into(),
|
||||
status: "pending".into(),
|
||||
payload: payload.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn artifact_response_from_row(
|
||||
row: &ConversationArtifactRow,
|
||||
) -> Result<ConversationArtifactResponse, CronError> {
|
||||
Ok(ConversationArtifactResponse {
|
||||
id: row.id,
|
||||
conversation_id: row.conversation_id.clone(),
|
||||
cron_job_id: row.cron_job_id.clone(),
|
||||
kind: parse_enum(&row.kind)?,
|
||||
status: parse_enum(&row.status)?,
|
||||
payload: serde_json::from_str(&row.payload)
|
||||
.map_err(|e| CronError::Scheduler(format!("invalid artifact payload JSON: {e}")))?,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn broadcast_artifact(
|
||||
broadcaster: &Arc<dyn EventBroadcaster>,
|
||||
row: &ConversationArtifactRow,
|
||||
) -> Result<(), CronError> {
|
||||
let payload = serde_json::to_value(artifact_response_from_row(row)?)
|
||||
.map_err(|e| CronError::Scheduler(format!("failed to serialize artifact event: {e}")))?;
|
||||
broadcaster.broadcast(WebSocketMessage::new("conversation.artifact", payload));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_enum<T: DeserializeOwned>(value: &str) -> Result<T, CronError> {
|
||||
serde_json::from_value(serde_json::Value::String(value.to_owned()))
|
||||
.map_err(|e| CronError::Scheduler(format!("invalid artifact enum value '{value}': {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{CreatedBy, CronJob, CronSchedule, ExecutionMode};
|
||||
|
||||
fn sample_job() -> CronJob {
|
||||
CronJob {
|
||||
id: "cron_1".into(),
|
||||
name: "Daily Report".into(),
|
||||
enabled: true,
|
||||
schedule: CronSchedule::Every {
|
||||
every_ms: 60_000,
|
||||
description: None,
|
||||
},
|
||||
message: "Run".into(),
|
||||
execution_mode: ExecutionMode::NewConversation,
|
||||
agent_config: None,
|
||||
conversation_id: "conv_1".into(),
|
||||
conversation_title: None,
|
||||
agent_type: "acp".into(),
|
||||
created_by: CreatedBy::User,
|
||||
skill_content: None,
|
||||
description: None,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
next_run_at: Some(2000),
|
||||
last_run_at: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
target_kind: crate::types::TargetKind::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_skill_suggest_response() {
|
||||
let row = build_skill_suggest_artifact(
|
||||
"conv_1",
|
||||
"cron_1",
|
||||
"daily-report",
|
||||
"Daily report",
|
||||
"---\nname: daily-report\n---\nUse it.",
|
||||
1234,
|
||||
);
|
||||
|
||||
let response = artifact_response_from_row(&row).unwrap();
|
||||
assert_eq!(response.kind, nomifun_api_types::ConversationArtifactKind::SkillSuggest);
|
||||
assert_eq!(response.status, nomifun_api_types::ConversationArtifactStatus::Pending);
|
||||
assert_eq!(response.payload["name"], "daily-report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cron_trigger_payload() {
|
||||
let row = build_cron_trigger_artifact("conv_1", &sample_job(), 1234);
|
||||
let response = artifact_response_from_row(&row).unwrap();
|
||||
assert_eq!(response.kind, nomifun_api_types::ConversationArtifactKind::CronTrigger);
|
||||
assert_eq!(response.payload["cron_job_id"], "cron_1");
|
||||
assert_eq!(response.payload["cron_job_name"], "Daily Report");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use dashmap::DashMap;
|
||||
|
||||
use nomifun_common::{TimestampMs, now_ms};
|
||||
|
||||
const IDLE_CLEANUP_THRESHOLD_MS: i64 = 3_600_000; // 1 hour
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ConversationState {
|
||||
is_processing: bool,
|
||||
last_active_at: TimestampMs,
|
||||
}
|
||||
|
||||
pub struct CronBusyGuard {
|
||||
states: DashMap<String, ConversationState>,
|
||||
}
|
||||
|
||||
impl CronBusyGuard {
|
||||
pub fn new() -> Self {
|
||||
Self { states: DashMap::new() }
|
||||
}
|
||||
|
||||
pub fn is_busy(&self, conversation_id: &str) -> bool {
|
||||
self.states
|
||||
.get(conversation_id)
|
||||
.map(|s| s.is_processing)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_processing(&self, conversation_id: &str, processing: bool) {
|
||||
let now = now_ms();
|
||||
self.states
|
||||
.entry(conversation_id.to_owned())
|
||||
.and_modify(|s| {
|
||||
s.is_processing = processing;
|
||||
s.last_active_at = now;
|
||||
})
|
||||
.or_insert(ConversationState {
|
||||
is_processing: processing,
|
||||
last_active_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
let cutoff = now_ms() - IDLE_CLEANUP_THRESHOLD_MS;
|
||||
self.states
|
||||
.retain(|_, state| state.is_processing || state.last_active_at > cutoff);
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.states.iter().filter(|entry| entry.is_processing).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CronBusyGuard {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_conversation_is_not_busy() {
|
||||
let guard = CronBusyGuard::new();
|
||||
assert!(!guard.is_busy("conv_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_processing_true_marks_busy() {
|
||||
let guard = CronBusyGuard::new();
|
||||
guard.set_processing("conv_1", true);
|
||||
assert!(guard.is_busy("conv_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_processing_false_marks_not_busy() {
|
||||
let guard = CronBusyGuard::new();
|
||||
guard.set_processing("conv_1", true);
|
||||
guard.set_processing("conv_1", false);
|
||||
assert!(!guard.is_busy("conv_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_conversations_independent() {
|
||||
let guard = CronBusyGuard::new();
|
||||
guard.set_processing("conv_1", true);
|
||||
guard.set_processing("conv_2", false);
|
||||
assert!(guard.is_busy("conv_1"));
|
||||
assert!(!guard.is_busy("conv_2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_count_reflects_processing() {
|
||||
let guard = CronBusyGuard::new();
|
||||
assert_eq!(guard.active_count(), 0);
|
||||
guard.set_processing("conv_1", true);
|
||||
guard.set_processing("conv_2", true);
|
||||
assert_eq!(guard.active_count(), 2);
|
||||
guard.set_processing("conv_1", false);
|
||||
assert_eq!(guard.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_idle_entries() {
|
||||
let guard = CronBusyGuard::new();
|
||||
// Insert a state with old timestamp
|
||||
guard.states.insert(
|
||||
"conv_old".to_owned(),
|
||||
ConversationState {
|
||||
is_processing: false,
|
||||
last_active_at: now_ms() - IDLE_CLEANUP_THRESHOLD_MS - 1000,
|
||||
},
|
||||
);
|
||||
// Insert a recent idle state
|
||||
guard.set_processing("conv_recent", false);
|
||||
|
||||
guard.cleanup();
|
||||
|
||||
assert!(guard.states.get("conv_old").is_none());
|
||||
assert!(guard.states.get("conv_recent").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_processing_entries_even_if_old() {
|
||||
let guard = CronBusyGuard::new();
|
||||
guard.states.insert(
|
||||
"conv_busy".to_owned(),
|
||||
ConversationState {
|
||||
is_processing: true,
|
||||
last_active_at: now_ms() - IDLE_CLEANUP_THRESHOLD_MS - 1000,
|
||||
},
|
||||
);
|
||||
|
||||
guard.cleanup();
|
||||
|
||||
assert!(guard.states.get("conv_busy").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_guard() {
|
||||
let guard = CronBusyGuard::default();
|
||||
assert_eq!(guard.active_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CronError {
|
||||
#[error("Cron job not found: {0}")]
|
||||
JobNotFound(String),
|
||||
|
||||
#[error("Invalid schedule: {0}")]
|
||||
InvalidSchedule(String),
|
||||
|
||||
#[error("Invalid cron expression: {0}")]
|
||||
InvalidCronExpression(String),
|
||||
|
||||
#[error("Invalid execution mode: {0}")]
|
||||
InvalidExecutionMode(String),
|
||||
|
||||
#[error("Invalid target kind: {0}")]
|
||||
InvalidTargetKind(String),
|
||||
|
||||
#[error("Invalid terminal config: {0}")]
|
||||
InvalidTerminalConfig(String),
|
||||
|
||||
#[error("Invalid created-by value: {0}")]
|
||||
InvalidCreatedBy(String),
|
||||
|
||||
#[error("Invalid job status: {0}")]
|
||||
InvalidJobStatus(String),
|
||||
|
||||
#[error("Invalid timezone: {0}")]
|
||||
InvalidTimezone(String),
|
||||
|
||||
#[error("Invalid skill content: {0}")]
|
||||
InvalidSkillContent(String),
|
||||
|
||||
#[error("Invalid agent config: {0}")]
|
||||
InvalidAgentConfig(String),
|
||||
|
||||
#[error("Scheduler error: {0}")]
|
||||
Scheduler(String),
|
||||
|
||||
#[error(transparent)]
|
||||
App(#[from] AppError),
|
||||
|
||||
#[error("{0}")]
|
||||
Database(#[from] nomifun_db::DbError),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<CronError> for AppError {
|
||||
fn from(err: CronError) -> Self {
|
||||
match err {
|
||||
CronError::JobNotFound(msg) => AppError::NotFound(msg),
|
||||
CronError::InvalidSchedule(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidCronExpression(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidExecutionMode(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidTargetKind(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidTerminalConfig(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidCreatedBy(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidJobStatus(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidTimezone(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidSkillContent(msg) => AppError::BadRequest(msg),
|
||||
CronError::InvalidAgentConfig(msg) => AppError::BadRequest(msg),
|
||||
CronError::Scheduler(msg) => AppError::Internal(msg),
|
||||
CronError::App(app_err) => app_err,
|
||||
CronError::Database(db_err) => AppError::from(db_err),
|
||||
CronError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CronError {
|
||||
pub(crate) fn from_conversation_create(error: AppError) -> Self {
|
||||
match error {
|
||||
AppError::WorkspacePathEdgeWhitespace(_) => Self::App(error),
|
||||
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(_) => Self::App(error),
|
||||
other => Self::Scheduler(format!("create conversation: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn job_not_found_maps_to_not_found() {
|
||||
let err: AppError = CronError::JobNotFound("cron_abc".into()).into();
|
||||
assert!(matches!(err, AppError::NotFound(msg) if msg == "cron_abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_schedule_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidSchedule("missing kind".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cron_expression_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidCronExpression("bad expr".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_execution_mode_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidExecutionMode("unknown".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_created_by_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidCreatedBy("robot".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_job_status_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidJobStatus("unknown".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_timezone_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidTimezone("Mars/Olympus".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_skill_content_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidSkillContent("empty".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_agent_config_maps_to_bad_request() {
|
||||
let err: AppError = CronError::InvalidAgentConfig("missing backend".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_error_maps_to_internal() {
|
||||
let err: AppError = CronError::Scheduler("timer failed".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_error_passthrough_preserves_code() {
|
||||
let err: AppError = CronError::App(AppError::WorkspacePathEdgeWhitespace("/tmp/a b".into())).into();
|
||||
assert!(matches!(err, AppError::WorkspacePathEdgeWhitespace(msg) if msg == "/tmp/a b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_workspace_app_error_passthrough_preserves_code() {
|
||||
let err: AppError = CronError::App(AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(
|
||||
"/tmp/a b".into(),
|
||||
))
|
||||
.into();
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(msg) if msg == "/tmp/a b"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_error_maps_to_internal() {
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
|
||||
let err: AppError = CronError::Json(json_err).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_messages() {
|
||||
assert_eq!(
|
||||
CronError::JobNotFound("cron_1".into()).to_string(),
|
||||
"Cron job not found: cron_1"
|
||||
);
|
||||
assert_eq!(
|
||||
CronError::InvalidSchedule("bad".into()).to_string(),
|
||||
"Invalid schedule: bad"
|
||||
);
|
||||
assert_eq!(
|
||||
CronError::InvalidCronExpression("* *".into()).to_string(),
|
||||
"Invalid cron expression: * *"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{CronJobExecutedEvent, CronJobRemovedPayload, CronJobResponse, WebSocketMessage};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CronEventEmitter {
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
}
|
||||
|
||||
impl CronEventEmitter {
|
||||
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
|
||||
Self { broadcaster }
|
||||
}
|
||||
|
||||
pub fn emit_job_created(&self, job: &CronJobResponse) {
|
||||
self.broadcast("cron.job-created", job);
|
||||
}
|
||||
|
||||
pub fn emit_job_updated(&self, job: &CronJobResponse) {
|
||||
self.broadcast("cron.job-updated", job);
|
||||
}
|
||||
|
||||
pub fn emit_job_removed(&self, job_id: &str) {
|
||||
self.broadcast(
|
||||
"cron.job-removed",
|
||||
&CronJobRemovedPayload {
|
||||
job_id: job_id.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emit_job_executed(&self, job_id: &str, status: &str, err: Option<&str>) {
|
||||
self.broadcast(
|
||||
"cron.job-executed",
|
||||
&CronJobExecutedEvent {
|
||||
job_id: job_id.to_owned(),
|
||||
status: status.to_owned(),
|
||||
error: err.map(|s| s.to_owned()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emit_conversation_tips(&self, conversation_id: &str, content: &str, tip_type: &str) {
|
||||
let payload = json!({
|
||||
"conversation_id": conversation_id,
|
||||
"msg_id": ConversationService::mint_msg_id(),
|
||||
"type": "tips",
|
||||
"data": {
|
||||
"content": content,
|
||||
"type": tip_type,
|
||||
},
|
||||
"hidden": false,
|
||||
});
|
||||
self.broadcaster
|
||||
.broadcast(WebSocketMessage::new("message.stream", payload));
|
||||
}
|
||||
|
||||
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
|
||||
let value = match serde_json::to_value(payload) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(event_name, error = %e, "Failed to serialize event payload");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_api_types::{
|
||||
CronJobExecutedEvent, CronJobMetadataDto, CronJobPayloadDto, CronJobRemovedPayload, CronJobResponse,
|
||||
CronJobStateDto, CronJobTargetDto, CronScheduleDto,
|
||||
};
|
||||
|
||||
struct RecordingBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: std::sync::Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for RecordingBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_emitter() -> (CronEventEmitter, Arc<RecordingBroadcaster>) {
|
||||
let bc = Arc::new(RecordingBroadcaster::new());
|
||||
let emitter = CronEventEmitter::new(bc.clone());
|
||||
(emitter, bc)
|
||||
}
|
||||
|
||||
fn sample_response() -> CronJobResponse {
|
||||
CronJobResponse {
|
||||
id: "cron_123".into(),
|
||||
name: "Test Job".into(),
|
||||
description: Some("Test description".into()),
|
||||
enabled: true,
|
||||
schedule: CronScheduleDto::Every {
|
||||
every_ms: 60000,
|
||||
description: Some("every minute".into()),
|
||||
},
|
||||
target: CronJobTargetDto {
|
||||
payload: CronJobPayloadDto::Message { text: "hello".into() },
|
||||
execution_mode: Some("existing".into()),
|
||||
target_kind: "agent".into(),
|
||||
},
|
||||
metadata: CronJobMetadataDto {
|
||||
conversation_id: 1,
|
||||
conversation_title: None,
|
||||
agent_type: "acp".into(),
|
||||
created_by: "user".into(),
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
agent_config: None,
|
||||
},
|
||||
state: CronJobStateDto {
|
||||
next_run_at_ms: Some(61000),
|
||||
last_run_at_ms: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_created_event_shape() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
let resp = sample_response();
|
||||
emitter.emit_job_created(&resp);
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "cron.job-created");
|
||||
|
||||
let parsed: CronJobResponse = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.id, "cron_123");
|
||||
assert_eq!(parsed.name, "Test Job");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_updated_event_shape() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
let resp = sample_response();
|
||||
emitter.emit_job_updated(&resp);
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "cron.job-updated");
|
||||
|
||||
let parsed: CronJobResponse = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.id, "cron_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_removed_event_shape() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
emitter.emit_job_removed("cron_456");
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "cron.job-removed");
|
||||
|
||||
let parsed: CronJobRemovedPayload = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.job_id, "cron_456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_executed_success_event() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
emitter.emit_job_executed("cron_789", "ok", None);
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "cron.job-executed");
|
||||
|
||||
let parsed: CronJobExecutedEvent = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.job_id, "cron_789");
|
||||
assert_eq!(parsed.status, "ok");
|
||||
assert!(parsed.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_executed_error_event() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
emitter.emit_job_executed("cron_789", "error", Some("timeout"));
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let parsed: CronJobExecutedEvent = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.status, "error");
|
||||
assert_eq!(parsed.error.as_deref(), Some("timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_executed_skipped_event() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
emitter.emit_job_executed("cron_789", "skipped", None);
|
||||
|
||||
let events = bc.events();
|
||||
let parsed: CronJobExecutedEvent = serde_json::from_value(events[0].data.clone()).unwrap();
|
||||
assert_eq!(parsed.status, "skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_events_accumulate() {
|
||||
let (emitter, bc) = make_emitter();
|
||||
let resp = sample_response();
|
||||
emitter.emit_job_created(&resp);
|
||||
emitter.emit_job_updated(&resp);
|
||||
emitter.emit_job_removed("cron_123");
|
||||
emitter.emit_job_executed("cron_123", "ok", None);
|
||||
|
||||
let events = bc.events();
|
||||
assert_eq!(events.len(), 4);
|
||||
assert_eq!(events[0].name, "cron.job-created");
|
||||
assert_eq!(events[1].name, "cron.job-updated");
|
||||
assert_eq!(events[2].name, "cron.job-removed");
|
||||
assert_eq!(events[3].name, "cron.job-executed");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
//! Scheduled job engine: cron scheduler, executor, and lifecycle event emitter.
|
||||
mod artifacts;
|
||||
pub mod busy_guard;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod executor;
|
||||
pub mod prompt;
|
||||
pub mod routes;
|
||||
pub mod scheduler;
|
||||
pub mod service;
|
||||
pub mod sink;
|
||||
pub mod skill_file;
|
||||
pub mod skill_suggest;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
|
||||
pub use events::CronEventEmitter;
|
||||
pub use routes::cron_routes;
|
||||
pub use state::CronRouterState;
|
||||
@@ -0,0 +1,38 @@
|
||||
/// The fixed filename agents write skill suggestions to in the workspace root.
|
||||
pub const SKILL_SUGGEST_FILENAME: &str = "SKILL_SUGGEST.md";
|
||||
|
||||
/// New-conversation mode without a saved skill for agents that need the
|
||||
/// `SKILL_SUGGEST.md` request inline.
|
||||
pub fn build_new_conversation_prompt_with_skill_suggest(
|
||||
task_name: &str,
|
||||
schedule_desc: &str,
|
||||
user_prompt: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"[Scheduled Task Context]\nTask: {task_name}\nSchedule: {schedule_desc}\n\nRules:\n1. Execute the task directly — do NOT ask clarifying questions.\n2. Focus on producing useful, actionable output.\n3. If the task requires external data (news, weather, etc.), search for the latest information.\n4. After completing the task above, create a file named \"{SKILL_SUGGEST_FILENAME}\" in the current working directory (see instructions at the end).\n[/Scheduled Task Context]\n\n{user_prompt}\n\n---\n\n[Post-Task] After you have fully completed the task above, create a file named \"{SKILL_SUGGEST_FILENAME}\" in the current working directory to help future runs stay consistent. The file should follow this format:\n\n```markdown\n---\nname: <short kebab-case name, e.g. daily-greeting>\ndescription: <one-line description of what this task does>\n---\n\n<Instructions capturing the pattern you used: output format, tone, sources checked, steps taken, quality criteria. Use concrete details from this execution, not placeholders.>\n```\n\nIf you think the task is too simple or one-off to benefit from a skill file, you can skip this step."
|
||||
)
|
||||
}
|
||||
|
||||
/// New-conversation mode with an existing saved skill already linked into the
|
||||
/// agent workspace.
|
||||
pub fn build_new_conversation_with_skill_prompt(task_name: &str, user_prompt: &str) -> String {
|
||||
format!(
|
||||
"[Scheduled Task Context]\nTask: {task_name}\n\nThis is a scheduled task execution. A skill file with detailed instructions has been loaded\ninto your workspace. You MUST read and follow the skill instructions precisely.\n\nRules:\n1. Execute the task directly — do NOT ask clarifying questions.\n2. Follow the output format, tone, sources, and steps defined in the skill.\n3. If the task requires external data (news, weather, etc.), search for the latest information.\n[/Scheduled Task Context]\n\n{user_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Existing-conversation mode: wrap the raw task text so the model treats it as
|
||||
/// an automatic task instruction rather than as user chat.
|
||||
pub fn build_existing_conversation_prompt(task_name: &str, schedule_desc: &str, user_prompt: &str) -> String {
|
||||
format!(
|
||||
"[Scheduled Task Execution]\nTask: {task_name}\nSchedule: {schedule_desc}\n\nThis message is NOT a conversation from the user — it is a scheduled task triggered automatically.\nThe text below is a TASK INSTRUCTION that you must execute, not something the user is saying to you.\n\nRules:\n1. Treat the instruction as a command to perform, not as a chat message to respond to.\n2. Execute it directly — do NOT ask clarifying questions.\n3. If the task requires external data (news, weather, etc.), search for the latest information.\n\nTask instruction:\n{user_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Follow-up request asking the agent to write `SKILL_SUGGEST.md` after it has
|
||||
/// already completed the recurring task.
|
||||
pub fn build_skill_suggest_prompt(task_name: &str) -> String {
|
||||
format!(
|
||||
"The task \"{task_name}\" is a recurring scheduled task. Based on what you just did, please create a file named \"{SKILL_SUGGEST_FILENAME}\" in the current working directory to help future runs stay consistent.\n\nThe file should follow this format:\n\n```markdown\n---\nname: <short kebab-case name, e.g. daily-greeting>\ndescription: <one-line description of what this task does>\n---\n\n<Instructions capturing the pattern you used: output format, tone, sources checked, steps taken, quality criteria. Use concrete details from this execution, not placeholders.>\n```\n\nIf you think the task is too simple or one-off to benefit from a skill file, you can skip this."
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, ConversationResponse, CreateCronJobRequest, CronJobResponse, CronJobRunResponse,
|
||||
HasSkillResponse, ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest,
|
||||
UpdateCronJobRequest,
|
||||
};
|
||||
use nomifun_auth::CurrentUser;
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::service::CronService;
|
||||
use crate::state::CronRouterState;
|
||||
|
||||
pub fn cron_routes(state: CronRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/cron/jobs", get(list_jobs).post(create_job))
|
||||
.route(
|
||||
"/api/cron/jobs/{id}",
|
||||
get(get_job).put(update_job).delete(delete_job),
|
||||
)
|
||||
.route("/api/cron/jobs/{id}/run", post(run_now))
|
||||
.route("/api/cron/jobs/{id}/runs", get(list_runs_by_cron_job))
|
||||
.route("/api/cron/internal/system-resume", post(system_resume))
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/conversations",
|
||||
get(list_conversations_by_cron_job),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/skill",
|
||||
get(has_skill).post(save_skill).delete(delete_skill),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn create_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<CreateCronJobRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<CronJobResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let job = state.cron_service.add_job(req).await?;
|
||||
let resp = CronService::to_response(&job);
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
async fn list_jobs(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Query(query): Query<ListCronJobsQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CronJobResponse>>>, AppError> {
|
||||
let jobs = state.cron_service.list_jobs(&query).await?;
|
||||
let items: Vec<CronJobResponse> = jobs.iter().map(CronService::to_response).collect();
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
async fn get_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<CronJobResponse>>, AppError> {
|
||||
let job = state.cron_service.get_job(&id).await?;
|
||||
Ok(Json(ApiResponse::ok(CronService::to_response(&job))))
|
||||
}
|
||||
|
||||
async fn update_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<UpdateCronJobRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CronJobResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let job = state.cron_service.update_job(&id, req).await?;
|
||||
Ok(Json(ApiResponse::ok(CronService::to_response(&job))))
|
||||
}
|
||||
|
||||
async fn delete_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.cron_service.remove_job(&id).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn run_now(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<RunNowResponse>>, AppError> {
|
||||
let resp = state.cron_service.run_now(&id).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
async fn system_resume(
|
||||
State(state): State<CronRouterState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let is_internal = headers
|
||||
.get("x-nomifun-internal")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some("1");
|
||||
if !is_internal {
|
||||
return Err(AppError::Forbidden("internal route".into()));
|
||||
}
|
||||
|
||||
state.cron_service.handle_system_resume().await;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn save_skill(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SaveCronSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.cron_service.save_skill(&id, req).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn list_conversations_by_cron_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<ConversationResponse>>>, AppError> {
|
||||
let items = state
|
||||
.conversation_service
|
||||
.list_by_cron_job(&user.id, &id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
async fn list_runs_by_cron_job(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<CronJobRunResponse>>>, AppError> {
|
||||
let items = state.cron_service.list_runs(&id).await?;
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
async fn has_skill(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<HasSkillResponse>>, AppError> {
|
||||
let resp = state.cron_service.has_skill(&id).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
async fn delete_skill(
|
||||
State(state): State<CronRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.cron_service.delete_skill(&id).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use cron::Schedule;
|
||||
use dashmap::DashMap;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use nomifun_common::{TimestampMs, now_ms};
|
||||
|
||||
use crate::error::CronError;
|
||||
use crate::types::{CronJob, CronSchedule};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize a cron expression so both 5-field (standard Unix) and 6-field
|
||||
/// (seconds-prefixed, as required by the `cron` crate) forms are accepted.
|
||||
/// A 5-field expression is promoted by prepending `0 ` for the seconds field.
|
||||
pub(crate) fn normalize_cron_expr(expr: &str) -> String {
|
||||
let trimmed = expr.trim();
|
||||
let field_count = trimmed.split_whitespace().count();
|
||||
if field_count == 5 {
|
||||
format!("0 {trimmed}")
|
||||
} else {
|
||||
trimmed.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_cron_expression(expr: &str) -> Result<Schedule, CronError> {
|
||||
let normalized = normalize_cron_expr(expr);
|
||||
Schedule::from_str(&normalized).map_err(|e| CronError::InvalidCronExpression(format!("{expr}: {e}")))
|
||||
}
|
||||
|
||||
pub fn validate_timezone(tz: &str) -> Result<chrono_tz::Tz, CronError> {
|
||||
tz.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| CronError::InvalidTimezone(tz.to_owned()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next-run computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn compute_next_run(schedule: &CronSchedule, now: TimestampMs) -> Option<TimestampMs> {
|
||||
match schedule {
|
||||
CronSchedule::At { at_ms, .. } => Some(*at_ms),
|
||||
CronSchedule::Every { every_ms, .. } => {
|
||||
if *every_ms <= 0 {
|
||||
return None;
|
||||
}
|
||||
Some(now + *every_ms)
|
||||
}
|
||||
CronSchedule::Cron { expr, tz, .. } => compute_cron_next_run(expr, tz.as_deref(), now),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_cron_next_run(expr: &str, tz: Option<&str>, now: TimestampMs) -> Option<TimestampMs> {
|
||||
let normalized = normalize_cron_expr(expr);
|
||||
let schedule = Schedule::from_str(&normalized).ok()?;
|
||||
|
||||
if let Some(tz_str) = tz {
|
||||
let tz_parsed: chrono_tz::Tz = tz_str.parse().ok()?;
|
||||
let now_dt = tz_parsed.timestamp_millis_opt(now).single()?;
|
||||
let next = schedule.after(&now_dt).next()?;
|
||||
Some(next.timestamp_millis())
|
||||
} else {
|
||||
let now_dt = Utc.timestamp_millis_opt(now).single()?;
|
||||
let next = schedule.after(&now_dt).next()?;
|
||||
Some(next.timestamp_millis())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule validation for create/update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn validate_schedule(schedule: &CronSchedule) -> Result<(), CronError> {
|
||||
match schedule {
|
||||
CronSchedule::At { .. } => Ok(()),
|
||||
CronSchedule::Every { every_ms, .. } => {
|
||||
if *every_ms <= 0 {
|
||||
return Err(CronError::InvalidSchedule("every_ms must be positive".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
CronSchedule::Cron { expr, tz, .. } => {
|
||||
if expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_cron_expression(expr)?;
|
||||
if let Some(tz_str) = tz {
|
||||
validate_timezone(tz_str)?;
|
||||
}
|
||||
// Guard the silent-failure path: an expression can parse yet have no
|
||||
// upcoming occurrence (e.g. an impossible date). Such a job would be
|
||||
// created `enabled` with `next_run_at = None` and never scheduled,
|
||||
// with no error surfaced. Reject it loudly instead.
|
||||
if compute_cron_next_run(expr, tz.as_deref(), now_ms()).is_none() {
|
||||
return Err(CronError::InvalidCronExpression(format!(
|
||||
"{expr}: expression has no upcoming run time"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CronScheduler — manages tokio timers for scheduled jobs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub type TickCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
pub struct CronScheduler {
|
||||
handles: DashMap<String, JoinHandle<()>>,
|
||||
tick_callback: TickCallback,
|
||||
}
|
||||
|
||||
impl CronScheduler {
|
||||
pub fn new(tick_callback: TickCallback) -> Self {
|
||||
Self {
|
||||
handles: DashMap::new(),
|
||||
tick_callback,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_job(&self, job: &CronJob) {
|
||||
self.cancel_job(&job.id);
|
||||
|
||||
if !job.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(next_run_at) = job.next_run_at else {
|
||||
return;
|
||||
};
|
||||
|
||||
let job_id = job.id.clone();
|
||||
let schedule = job.schedule.clone();
|
||||
let callback = Arc::clone(&self.tick_callback);
|
||||
|
||||
let handle = match &schedule {
|
||||
CronSchedule::At { .. } => spawn_at_timer(job_id, next_run_at, callback),
|
||||
CronSchedule::Every { every_ms, .. } => spawn_every_timer(job_id, next_run_at, *every_ms, callback),
|
||||
CronSchedule::Cron { expr, tz, .. } => {
|
||||
spawn_cron_timer(job_id, next_run_at, expr.clone(), tz.clone(), callback)
|
||||
}
|
||||
};
|
||||
|
||||
self.handles.insert(job.id.clone(), handle);
|
||||
}
|
||||
|
||||
pub fn cancel_job(&self, job_id: &str) {
|
||||
if let Some((_, handle)) = self.handles.remove(job_id) {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reschedule_job(&self, job: &CronJob) {
|
||||
self.schedule_job(job);
|
||||
}
|
||||
|
||||
pub fn cancel_all(&self) {
|
||||
for entry in self.handles.iter() {
|
||||
entry.value().abort();
|
||||
}
|
||||
self.handles.clear();
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.handles.len()
|
||||
}
|
||||
|
||||
pub fn is_scheduled(&self, job_id: &str) -> bool {
|
||||
self.handles.contains_key(job_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CronScheduler {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_all();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timer spawn helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_at_timer(job_id: String, run_at: TimestampMs, callback: TickCallback) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let delay = delay_until(run_at);
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay as u64)).await;
|
||||
}
|
||||
callback(job_id);
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_every_timer(
|
||||
job_id: String,
|
||||
first_run_at: TimestampMs,
|
||||
every_ms: i64,
|
||||
callback: TickCallback,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let initial_delay = delay_until(first_run_at);
|
||||
if initial_delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(initial_delay as u64)).await;
|
||||
}
|
||||
callback(job_id.clone());
|
||||
|
||||
let interval_duration = tokio::time::Duration::from_millis(every_ms as u64);
|
||||
let mut interval = tokio::time::interval(interval_duration);
|
||||
interval.tick().await; // first tick fires immediately, skip it
|
||||
loop {
|
||||
interval.tick().await;
|
||||
callback(job_id.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_cron_timer(
|
||||
job_id: String,
|
||||
first_run_at: TimestampMs,
|
||||
expr: String,
|
||||
tz: Option<String>,
|
||||
callback: TickCallback,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let initial_delay = delay_until(first_run_at);
|
||||
if initial_delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(initial_delay as u64)).await;
|
||||
}
|
||||
callback(job_id.clone());
|
||||
|
||||
loop {
|
||||
let now = now_ms();
|
||||
let next = compute_cron_next_run(&expr, tz.as_deref(), now);
|
||||
let Some(next_at) = next else {
|
||||
break;
|
||||
};
|
||||
let delay = delay_until(next_at);
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay as u64)).await;
|
||||
}
|
||||
callback(job_id.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delay_until(target_ms: TimestampMs) -> i64 {
|
||||
let now = now_ms();
|
||||
(target_ms - now).max(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- compute_next_run ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn next_run_at_returns_at_ms() {
|
||||
let schedule = CronSchedule::At {
|
||||
at_ms: 5000,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), Some(5000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_at_past_still_returns_at_ms() {
|
||||
let schedule = CronSchedule::At {
|
||||
at_ms: 500,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), Some(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_every_adds_interval() {
|
||||
let schedule = CronSchedule::Every {
|
||||
every_ms: 60000,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), Some(61000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_every_zero_returns_none() {
|
||||
let schedule = CronSchedule::Every {
|
||||
every_ms: 0,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_every_negative_returns_none() {
|
||||
let schedule = CronSchedule::Every {
|
||||
every_ms: -100,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_cron_returns_future_time() {
|
||||
let now = now_ms();
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 * * * * *".into(), // every minute
|
||||
tz: None,
|
||||
description: None,
|
||||
};
|
||||
let next = compute_next_run(&schedule, now);
|
||||
assert!(next.is_some());
|
||||
assert!(next.unwrap() > now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_cron_with_timezone() {
|
||||
let now = now_ms();
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 * * * * *".into(),
|
||||
tz: Some("Asia/Shanghai".into()),
|
||||
description: None,
|
||||
};
|
||||
let next = compute_next_run(&schedule, now);
|
||||
assert!(next.is_some());
|
||||
assert!(next.unwrap() > now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_cron_invalid_expr_returns_none() {
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "invalid".into(),
|
||||
tz: None,
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_run_cron_invalid_tz_returns_none() {
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 * * * * *".into(),
|
||||
tz: Some("Mars/Olympus".into()),
|
||||
description: None,
|
||||
};
|
||||
assert_eq!(compute_next_run(&schedule, 1000), None);
|
||||
}
|
||||
|
||||
// -- validate_schedule ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_at_schedule() {
|
||||
let s = CronSchedule::At {
|
||||
at_ms: 1000,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_every_positive() {
|
||||
let s = CronSchedule::Every {
|
||||
every_ms: 1000,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_every_zero_fails() {
|
||||
let s = CronSchedule::Every {
|
||||
every_ms: 0,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_every_negative_fails() {
|
||||
let s = CronSchedule::Every {
|
||||
every_ms: -1,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_valid() {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: "0 */5 * * * *".into(),
|
||||
tz: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_empty_expr_is_manual_only() {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: String::new(),
|
||||
tz: None,
|
||||
description: Some("manual".into()),
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok());
|
||||
assert_eq!(compute_next_run(&s, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_with_valid_tz() {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: "0 0 9 * * *".into(),
|
||||
tz: Some("Asia/Shanghai".into()),
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_invalid_expr() {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: "invalid".into(),
|
||||
tz: None,
|
||||
description: None,
|
||||
};
|
||||
let err = validate_schedule(&s).unwrap_err();
|
||||
assert!(matches!(err, CronError::InvalidCronExpression(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_invalid_tz() {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: "0 * * * * *".into(),
|
||||
tz: Some("Invalid/TZ".into()),
|
||||
description: None,
|
||||
};
|
||||
let err = validate_schedule(&s).unwrap_err();
|
||||
assert!(matches!(err, CronError::InvalidTimezone(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_rejects_expr_with_no_upcoming_run() {
|
||||
// Feb 30 never occurs: the expression parses but has no next run, which
|
||||
// would otherwise be created enabled yet never scheduled, silently.
|
||||
let s = CronSchedule::Cron {
|
||||
expr: "0 0 0 30 2 ?".into(),
|
||||
tz: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_minute_level_is_accepted() {
|
||||
for expr in ["* * * * *", "*/1 * * * *", "0 */5 * * * ?", "0 * * * * ?"] {
|
||||
let s = CronSchedule::Cron {
|
||||
expr: expr.into(),
|
||||
tz: Some("Asia/Shanghai".into()),
|
||||
description: None,
|
||||
};
|
||||
assert!(validate_schedule(&s).is_ok(), "expected {expr} to validate");
|
||||
}
|
||||
}
|
||||
|
||||
// -- validate_cron_expression / validate_timezone -------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_cron_expression_valid() {
|
||||
assert!(validate_cron_expression("0 */5 * * * *").is_ok());
|
||||
assert!(validate_cron_expression("0 0 9 * * *").is_ok());
|
||||
assert!(validate_cron_expression("0 0 0 1 1 *").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_expression_accepts_five_field_unix_form() {
|
||||
// Standard 5-field Unix cron (minute hour day month dow) — must be
|
||||
// auto-normalized to the 6-field form the `cron` crate requires.
|
||||
assert!(validate_cron_expression("0 9 * * *").is_ok());
|
||||
assert!(validate_cron_expression("30 14 * * MON-FRI").is_ok());
|
||||
assert!(validate_cron_expression("0 10 * * WED").is_ok());
|
||||
assert!(validate_cron_expression("0 * * * *").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cron_expression_invalid() {
|
||||
assert!(validate_cron_expression("not a cron").is_err());
|
||||
assert!(validate_cron_expression("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cron_expr_leaves_six_field_alone() {
|
||||
assert_eq!(normalize_cron_expr("0 0 9 * * *"), "0 0 9 * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cron_expr_promotes_five_field() {
|
||||
assert_eq!(normalize_cron_expr("0 9 * * *"), "0 0 9 * * *");
|
||||
assert_eq!(normalize_cron_expr(" 30 14 * * MON-FRI "), "0 30 14 * * MON-FRI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_timezone_valid() {
|
||||
assert!(validate_timezone("UTC").is_ok());
|
||||
assert!(validate_timezone("Asia/Shanghai").is_ok());
|
||||
assert!(validate_timezone("America/New_York").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_timezone_invalid() {
|
||||
assert!(validate_timezone("Invalid/TZ").is_err());
|
||||
assert!(validate_timezone("Mars").is_err());
|
||||
}
|
||||
|
||||
// -- CronScheduler -------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_schedule_and_cancel() {
|
||||
let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called_clone = Arc::clone(&called);
|
||||
let scheduler = CronScheduler::new(Arc::new(move |_id| {
|
||||
called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
|
||||
let job = make_test_job("cron_1", true, Some(now_ms() + 100_000));
|
||||
scheduler.schedule_job(&job);
|
||||
assert!(scheduler.is_scheduled("cron_1"));
|
||||
assert_eq!(scheduler.active_count(), 1);
|
||||
|
||||
scheduler.cancel_job("cron_1");
|
||||
assert!(!scheduler.is_scheduled("cron_1"));
|
||||
assert_eq!(scheduler.active_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_disabled_job_not_scheduled() {
|
||||
let scheduler = CronScheduler::new(Arc::new(|_| {}));
|
||||
let job = make_test_job("cron_1", false, Some(now_ms() + 100_000));
|
||||
scheduler.schedule_job(&job);
|
||||
assert!(!scheduler.is_scheduled("cron_1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_no_next_run_not_scheduled() {
|
||||
let scheduler = CronScheduler::new(Arc::new(|_| {}));
|
||||
let job = make_test_job("cron_1", true, None);
|
||||
scheduler.schedule_job(&job);
|
||||
assert!(!scheduler.is_scheduled("cron_1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_cancel_all() {
|
||||
let scheduler = CronScheduler::new(Arc::new(|_| {}));
|
||||
let future = now_ms() + 100_000;
|
||||
scheduler.schedule_job(&make_test_job("cron_1", true, Some(future)));
|
||||
scheduler.schedule_job(&make_test_job("cron_2", true, Some(future)));
|
||||
scheduler.schedule_job(&make_test_job("cron_3", true, Some(future)));
|
||||
assert_eq!(scheduler.active_count(), 3);
|
||||
|
||||
scheduler.cancel_all();
|
||||
assert_eq!(scheduler.active_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_reschedule_replaces_timer() {
|
||||
let scheduler = CronScheduler::new(Arc::new(|_| {}));
|
||||
let job = make_test_job("cron_1", true, Some(now_ms() + 100_000));
|
||||
scheduler.schedule_job(&job);
|
||||
assert!(scheduler.is_scheduled("cron_1"));
|
||||
|
||||
let updated = CronJob {
|
||||
next_run_at: Some(now_ms() + 200_000),
|
||||
..job
|
||||
};
|
||||
scheduler.reschedule_job(&updated);
|
||||
assert!(scheduler.is_scheduled("cron_1"));
|
||||
assert_eq!(scheduler.active_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_cancel_nonexistent_no_panic() {
|
||||
let scheduler = CronScheduler::new(Arc::new(|_| {}));
|
||||
scheduler.cancel_job("nonexistent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_at_timer_fires_callback() {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
|
||||
let scheduler = CronScheduler::new(Arc::new(move |id| {
|
||||
if let Some(sender) = tx.lock().unwrap().take() {
|
||||
let _ = sender.send(id);
|
||||
}
|
||||
}));
|
||||
|
||||
let job = CronJob {
|
||||
schedule: CronSchedule::At {
|
||||
at_ms: now_ms() + 50,
|
||||
description: None,
|
||||
},
|
||||
next_run_at: Some(now_ms() + 50),
|
||||
..make_test_job("cron_at", true, Some(now_ms() + 50))
|
||||
};
|
||||
scheduler.schedule_job(&job);
|
||||
|
||||
let result = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().unwrap(), "cron_at");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_every_timer_fires_callback() {
|
||||
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let scheduler = CronScheduler::new(Arc::new(move |_id| {
|
||||
counter_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
|
||||
let job = CronJob {
|
||||
schedule: CronSchedule::Every {
|
||||
every_ms: 50,
|
||||
description: None,
|
||||
},
|
||||
next_run_at: Some(now_ms() + 50),
|
||||
..make_test_job("cron_every", true, Some(now_ms() + 50))
|
||||
};
|
||||
scheduler.schedule_job(&job);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
|
||||
scheduler.cancel_job("cron_every");
|
||||
|
||||
let count = counter.load(std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(count >= 2, "expected at least 2 ticks, got {count}");
|
||||
}
|
||||
|
||||
// -- Test helper ----------------------------------------------------------
|
||||
|
||||
fn make_test_job(id: &str, enabled: bool, next_run_at: Option<TimestampMs>) -> CronJob {
|
||||
use crate::types::{CreatedBy, ExecutionMode, TargetKind};
|
||||
CronJob {
|
||||
id: id.to_owned(),
|
||||
name: "Test".into(),
|
||||
enabled,
|
||||
schedule: CronSchedule::Every {
|
||||
every_ms: 60000,
|
||||
description: None,
|
||||
},
|
||||
message: "test message".into(),
|
||||
execution_mode: ExecutionMode::Existing,
|
||||
agent_config: None,
|
||||
conversation_id: "conv_1".into(),
|
||||
conversation_title: None,
|
||||
agent_type: "acp".into(),
|
||||
created_by: CreatedBy::User,
|
||||
skill_content: None,
|
||||
description: None,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
next_run_at,
|
||||
last_run_at: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
target_kind: TargetKind::Agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
//! Backend implementation of the agent-side `CronSink` trait, delegating to
|
||||
//! `CronService`. Built per-conversation by the agent factory so the in-process
|
||||
//! nomi agent can schedule / list / delete its own recurring prompts. Mirrors
|
||||
//! `nomifun_requirement::RequirementServiceSink`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::{CronJobSummary, CronSink};
|
||||
use nomifun_api_types::{CreateCronJobRequest, CronScheduleDto, ListCronJobsQuery};
|
||||
|
||||
use crate::service::CronService;
|
||||
|
||||
/// Process-wide handle to the single `CronService`, set once at startup. The
|
||||
/// agent factory builds per-conversation cron sinks long after startup (when a
|
||||
/// conversation runs), but `CronService` is created late (it depends on the
|
||||
/// agent/conversation machinery the factory feeds), so a late-bound singleton
|
||||
/// is the clean way to bridge the two without threading a handle through every
|
||||
/// service layer. Set exactly once via [`set_process_cron_service`].
|
||||
static CRON_SERVICE: OnceLock<Arc<CronService>> = OnceLock::new();
|
||||
|
||||
/// Register the process `CronService` so the agent's native cron tools can reach
|
||||
/// it. Call once at startup, right after the service is constructed.
|
||||
pub fn set_process_cron_service(service: Arc<CronService>) {
|
||||
let _ = CRON_SERVICE.set(service);
|
||||
}
|
||||
|
||||
/// Build a conversation-bound [`CronSink`] over the process `CronService`, or an
|
||||
/// [`UnavailableCronSink`] if it has not been registered yet (only possible
|
||||
/// before startup finishes — never during a live conversation).
|
||||
pub fn cron_sink_for(conversation_id: String) -> Arc<dyn CronSink> {
|
||||
match CRON_SERVICE.get() {
|
||||
Some(service) => CronServiceSink::into_arc(service.clone(), conversation_id),
|
||||
None => Arc::new(UnavailableCronSink),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback sink used only if the process `CronService` is not yet registered.
|
||||
/// Every operation reports the not-ready state instead of panicking.
|
||||
pub struct UnavailableCronSink;
|
||||
|
||||
#[async_trait]
|
||||
impl CronSink for UnavailableCronSink {
|
||||
async fn create(&self, _name: &str, _cron: &str, _prompt: &str) -> Result<String, String> {
|
||||
Err("cron service is not available yet".to_string())
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<CronJobSummary>, String> {
|
||||
Err("cron service is not available yet".to_string())
|
||||
}
|
||||
async fn delete(&self, _job_id: &str) -> Result<(), String> {
|
||||
Err("cron service is not available yet".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `CronSink` bound to one (nomi) conversation.
|
||||
pub struct CronServiceSink {
|
||||
service: Arc<CronService>,
|
||||
/// The agent's conversation id (numeric string).
|
||||
conversation_id: String,
|
||||
}
|
||||
|
||||
impl CronServiceSink {
|
||||
/// Build the sink as a trait object ready to inject into the agent factory.
|
||||
pub fn into_arc(service: Arc<CronService>, conversation_id: String) -> Arc<dyn CronSink> {
|
||||
Arc::new(Self {
|
||||
service,
|
||||
conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn conv_i64(&self) -> Result<i64, String> {
|
||||
self.conversation_id
|
||||
.parse::<i64>()
|
||||
.map_err(|_| format!("conversation id '{}' is not numeric", self.conversation_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CronSink for CronServiceSink {
|
||||
async fn create(&self, name: &str, cron_expr: &str, prompt: &str) -> Result<String, String> {
|
||||
// Bound to the agent's own conversation: agent_type "nomi" +
|
||||
// execution_mode Existing makes the job re-run this conversation's nomi
|
||||
// agent (model resolved from the conversation at run time, so no
|
||||
// agent_config needed). Validated by CronService::add_job.
|
||||
let req = CreateCronJobRequest {
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
schedule: CronScheduleDto::Cron {
|
||||
expr: cron_expr.to_string(),
|
||||
tz: None,
|
||||
description: None,
|
||||
},
|
||||
prompt: Some(prompt.to_string()),
|
||||
message: None,
|
||||
conversation_id: self.conv_i64()?,
|
||||
conversation_title: None,
|
||||
agent_type: "nomi".to_string(),
|
||||
created_by: "agent".to_string(),
|
||||
execution_mode: None, // -> Existing
|
||||
agent_config: None,
|
||||
target_kind: "agent".to_string(),
|
||||
};
|
||||
let job = self.service.add_job(req).await.map_err(|e| e.to_string())?;
|
||||
Ok(job.id)
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<CronJobSummary>, String> {
|
||||
let conv = self.conv_i64()?;
|
||||
let jobs = self
|
||||
.service
|
||||
.list_jobs(&ListCronJobsQuery {
|
||||
conversation_id: Some(conv),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(jobs
|
||||
.into_iter()
|
||||
.map(|j| CronJobSummary {
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
schedule: j
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{:?}", j.schedule)),
|
||||
enabled: j.enabled,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, job_id: &str) -> Result<(), String> {
|
||||
self.service
|
||||
.remove_job(job_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use crate::error::CronError;
|
||||
|
||||
pub const CRON_SKILLS_REL_DIR: &str = "cron/skills";
|
||||
pub const CRON_SKILL_DIR_PREFIX: &str = "cron-";
|
||||
pub const SKILL_FILE_NAME: &str = "SKILL.md";
|
||||
|
||||
const PLACEHOLDER_PATTERNS: &[&str] = &[
|
||||
"skill-name",
|
||||
"one-line description",
|
||||
"your-skill-name",
|
||||
"your skill name",
|
||||
"description of",
|
||||
];
|
||||
const PLACEHOLDER_BODY_PATTERNS: &[&str] = &[
|
||||
"(full skill.md body",
|
||||
"full skill.md body",
|
||||
"(clear instructions for executing this task",
|
||||
"<full instructions: output format, tone, sources to check",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedSkillContent {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub fn cron_skill_name(job_id: &str) -> Result<String, CronError> {
|
||||
validate_job_id(job_id)?;
|
||||
Ok(format!("{CRON_SKILL_DIR_PREFIX}{job_id}"))
|
||||
}
|
||||
|
||||
pub fn cron_skill_dir(data_dir: &Path, job_id: &str) -> Result<PathBuf, CronError> {
|
||||
Ok(data_dir.join(CRON_SKILLS_REL_DIR).join(cron_skill_name(job_id)?))
|
||||
}
|
||||
|
||||
pub fn cron_skill_file_path(data_dir: &Path, job_id: &str) -> Result<PathBuf, CronError> {
|
||||
Ok(cron_skill_dir(data_dir, job_id)?.join(SKILL_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn build_skill_content(name: &str, description: &str, prompt: &str, schedule_description: Option<&str>) -> String {
|
||||
let sanitized_desc = description
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let mut lines = vec![
|
||||
"---".to_owned(),
|
||||
format!("name: {name}"),
|
||||
format!("description: {sanitized_desc}"),
|
||||
"---".to_owned(),
|
||||
String::new(),
|
||||
format!("This is a scheduled task: **{name}**"),
|
||||
];
|
||||
|
||||
if let Some(schedule_description) = schedule_description {
|
||||
lines.push(format!("Schedule: {schedule_description}"));
|
||||
}
|
||||
|
||||
lines.extend([
|
||||
String::new(),
|
||||
"## Instructions".to_owned(),
|
||||
String::new(),
|
||||
"You are executing a scheduled task. Follow the instructions below directly.".to_owned(),
|
||||
"Do NOT ask clarifying questions — just execute the task and produce the result.".to_owned(),
|
||||
String::new(),
|
||||
prompt.to_owned(),
|
||||
]);
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn parse_skill_content(content: &str) -> Result<ParsedSkillContent, CronError> {
|
||||
let (name, description, body) = parse_frontmatter(content)?;
|
||||
let prompt = extract_prompt_from_body(&body);
|
||||
Ok(ParsedSkillContent {
|
||||
name,
|
||||
description,
|
||||
body: prompt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_skill_content(content: &str) -> Result<ParsedSkillContent, CronError> {
|
||||
let (name, description, body) = parse_frontmatter(content)?;
|
||||
let trimmed_body = body.trim();
|
||||
if trimmed_body.is_empty() {
|
||||
return Err(CronError::InvalidSkillContent("skill file body cannot be empty".into()));
|
||||
}
|
||||
if is_placeholder(&name, PLACEHOLDER_PATTERNS) {
|
||||
return Err(CronError::InvalidSkillContent(
|
||||
"skill name looks like a template placeholder".into(),
|
||||
));
|
||||
}
|
||||
if is_placeholder(&description, PLACEHOLDER_PATTERNS) {
|
||||
return Err(CronError::InvalidSkillContent(
|
||||
"skill description looks like a template placeholder".into(),
|
||||
));
|
||||
}
|
||||
if is_placeholder(trimmed_body, PLACEHOLDER_BODY_PATTERNS) {
|
||||
return Err(CronError::InvalidSkillContent(
|
||||
"skill body looks like a template placeholder".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ParsedSkillContent {
|
||||
name,
|
||||
description,
|
||||
body: trimmed_body.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn content_hash(content: &str) -> String {
|
||||
let normalized = normalize_for_hash(content);
|
||||
let mut hasher = DefaultHasher::new();
|
||||
normalized.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub async fn write_skill_file(
|
||||
data_dir: &Path,
|
||||
job_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
prompt: &str,
|
||||
schedule_description: Option<&str>,
|
||||
) -> Result<PathBuf, CronError> {
|
||||
let content = build_skill_content(name, description, prompt, schedule_description);
|
||||
write_raw_skill_file(data_dir, job_id, &content).await
|
||||
}
|
||||
|
||||
pub async fn write_raw_skill_file(data_dir: &Path, job_id: &str, raw_content: &str) -> Result<PathBuf, CronError> {
|
||||
validate_skill_content(raw_content)?;
|
||||
|
||||
let dir = cron_skill_dir(data_dir, job_id)?;
|
||||
let file_path = dir.join(SKILL_FILE_NAME);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|err| CronError::InvalidSkillContent(err.to_string()))?;
|
||||
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let temp_path = dir.join(format!("{SKILL_FILE_NAME}.tmp-{}-{nonce}", std::process::id()));
|
||||
|
||||
fs::write(&temp_path, raw_content)
|
||||
.await
|
||||
.map_err(|err| CronError::InvalidSkillContent(err.to_string()))?;
|
||||
fs::rename(&temp_path, &file_path)
|
||||
.await
|
||||
.map_err(|err| CronError::InvalidSkillContent(err.to_string()))?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
pub async fn read_skill_content(data_dir: &Path, job_id: &str) -> Result<Option<String>, CronError> {
|
||||
let file_path = cron_skill_file_path(data_dir, job_id)?;
|
||||
match fs::read_to_string(file_path).await {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(CronError::InvalidSkillContent(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has_skill_file(data_dir: &Path, job_id: &str) -> Result<bool, CronError> {
|
||||
let file_path = cron_skill_file_path(data_dir, job_id)?;
|
||||
match fs::metadata(file_path).await {
|
||||
Ok(metadata) => Ok(metadata.is_file()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(CronError::InvalidSkillContent(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_skill_file(data_dir: &Path, job_id: &str) -> Result<(), CronError> {
|
||||
let dir = cron_skill_dir(data_dir, job_id)?;
|
||||
match fs::remove_dir_all(dir).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(CronError::InvalidSkillContent(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_job_id(job_id: &str) -> Result<(), CronError> {
|
||||
if job_id.is_empty() || job_id.contains('/') || job_id.contains('\\') || job_id.contains("..") {
|
||||
return Err(CronError::InvalidSkillContent(format!("invalid cron job id: {job_id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_frontmatter(content: &str) -> Result<(String, String, String), CronError> {
|
||||
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let mut lines = normalized.lines();
|
||||
if lines.next() != Some("---") {
|
||||
return Err(CronError::InvalidSkillContent(
|
||||
"skill file must start with YAML frontmatter".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut frontmatter = Vec::new();
|
||||
let mut found_end = false;
|
||||
for line in &mut lines {
|
||||
if line == "---" {
|
||||
found_end = true;
|
||||
break;
|
||||
}
|
||||
frontmatter.push(line);
|
||||
}
|
||||
if !found_end {
|
||||
return Err(CronError::InvalidSkillContent(
|
||||
"skill file is missing the closing frontmatter delimiter".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let name = frontmatter
|
||||
.iter()
|
||||
.find_map(|line| line.strip_prefix("name:"))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CronError::InvalidSkillContent("missing skill name".into()))?
|
||||
.to_owned();
|
||||
let description = frontmatter
|
||||
.iter()
|
||||
.find_map(|line| line.strip_prefix("description:"))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CronError::InvalidSkillContent("missing skill description".into()))?
|
||||
.to_owned();
|
||||
|
||||
let mut body_lines: Vec<&str> = lines.collect();
|
||||
while matches!(body_lines.first(), Some(line) if line.is_empty()) {
|
||||
body_lines.remove(0);
|
||||
}
|
||||
let body = body_lines.join("\n");
|
||||
Ok((name, description, body))
|
||||
}
|
||||
|
||||
fn extract_prompt_from_body(body: &str) -> String {
|
||||
let instructions_idx = match body.find("## Instructions") {
|
||||
Some(idx) => idx,
|
||||
None => return body.trim_end().to_owned(),
|
||||
};
|
||||
|
||||
let after_heading = &body[instructions_idx..];
|
||||
let lines: Vec<&str> = after_heading.split('\n').collect();
|
||||
let mut start_idx = lines.len();
|
||||
for (idx, line) in lines.iter().enumerate().skip(1) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("You are executing") || trimmed.starts_with("Do NOT ask") {
|
||||
continue;
|
||||
}
|
||||
start_idx = idx;
|
||||
break;
|
||||
}
|
||||
|
||||
if start_idx >= lines.len() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
lines[start_idx..].join("\n").trim_end().to_owned()
|
||||
}
|
||||
|
||||
fn is_placeholder(value: &str, patterns: &[&str]) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
patterns.iter().any(|pattern| normalized.starts_with(pattern))
|
||||
}
|
||||
|
||||
fn normalize_for_hash(content: &str) -> String {
|
||||
content.replace("\r\n", "\n").replace('\r', "\n").trim().to_owned()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_db::IConversationRepository;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::fs;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::artifacts::{broadcast_artifact, build_skill_suggest_artifact};
|
||||
use crate::error::CronError;
|
||||
use crate::prompt::SKILL_SUGGEST_FILENAME;
|
||||
use crate::skill_file::{content_hash, has_skill_file, validate_skill_content};
|
||||
|
||||
const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 3000];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SkillSuggestDetector {
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
conversation_repo: Arc<dyn IConversationRepository>,
|
||||
data_dir: PathBuf,
|
||||
last_hash_by_job: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl SkillSuggestDetector {
|
||||
pub fn new(
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
conversation_repo: Arc<dyn IConversationRepository>,
|
||||
data_dir: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
broadcaster,
|
||||
conversation_repo,
|
||||
data_dir,
|
||||
last_hash_by_job: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_check(&self, conversation_id: String, job_id: String, workspace: String) {
|
||||
let detector = self.clone();
|
||||
tokio::spawn(async move {
|
||||
detector.check_with_retry(&conversation_id, &job_id, &workspace).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn check_with_retry(&self, conversation_id: &str, job_id: &str, workspace: &str) {
|
||||
for delay_ms in RETRY_DELAYS_MS {
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
match self.check_and_emit(conversation_id, job_id, workspace).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => continue,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
job_id,
|
||||
error = %err,
|
||||
"Failed checking SKILL_SUGGEST.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_and_emit(&self, conversation_id: &str, job_id: &str, workspace: &str) -> Result<bool, CronError> {
|
||||
if workspace.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if has_skill_file(&self.data_dir, job_id).await? {
|
||||
self.clear_last_hash(job_id);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let file_path = Path::new(workspace).join(SKILL_SUGGEST_FILENAME);
|
||||
let content = match fs::read_to_string(&file_path).await {
|
||||
Ok(content) => content,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(err) => {
|
||||
return Err(CronError::InvalidSkillContent(err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let validated = match validate_skill_content(&content) {
|
||||
Ok(validated) => validated,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
let hash = content_hash(&content);
|
||||
if self.last_hash(job_id).as_deref() == Some(hash.as_str()) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
self.set_last_hash(job_id, hash);
|
||||
self.emit(
|
||||
conversation_id,
|
||||
job_id,
|
||||
&validated.name,
|
||||
&validated.description,
|
||||
&content,
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn emit(&self, conversation_id: &str, job_id: &str, name: &str, description: &str, skill_content: &str) {
|
||||
self.persist_and_broadcast(conversation_id, job_id, name, description, skill_content)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn persist_and_broadcast(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
job_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
skill_content: &str,
|
||||
) {
|
||||
let row = build_skill_suggest_artifact(conversation_id, job_id, name, description, skill_content, now_ms());
|
||||
|
||||
let row = match self.conversation_repo.upsert_artifact(&row).await {
|
||||
Ok(row) => row,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
job_id,
|
||||
error = %err,
|
||||
"Failed persisting cron skill suggestion artifact"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = broadcast_artifact(&self.broadcaster, &row) {
|
||||
warn!(
|
||||
conversation_id,
|
||||
job_id,
|
||||
error = %err,
|
||||
"Failed broadcasting cron skill suggestion artifact"
|
||||
);
|
||||
return;
|
||||
}
|
||||
debug!(conversation_id, job_id, "Broadcasted cron skill suggestion artifact");
|
||||
}
|
||||
|
||||
fn last_hash(&self, job_id: &str) -> Option<String> {
|
||||
self.last_hash_by_job
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|hashes| hashes.get(job_id).cloned())
|
||||
}
|
||||
|
||||
fn set_last_hash(&self, job_id: &str, hash: String) {
|
||||
if let Ok(mut hashes) = self.last_hash_by_job.lock() {
|
||||
hashes.insert(job_id.to_owned(), hash);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_last_hash(&self, job_id: &str) {
|
||||
if let Ok(mut hashes) = self.last_hash_by_job.lock() {
|
||||
hashes.remove(job_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_db::models::{ConversationRow, CronJobRow};
|
||||
use nomifun_db::{
|
||||
ICronRepository, SqliteConversationRepository, SqliteCronRepository, SqlitePool, init_database_memory,
|
||||
};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_conversation(id: &str) -> ConversationRow {
|
||||
ConversationRow {
|
||||
// `create` allocates the PK (AUTOINCREMENT) and ignores this field,
|
||||
// but parse the test's id string so the struct is well-typed.
|
||||
id: id.parse::<i64>().unwrap_or_default(),
|
||||
user_id: "system_default_user".into(),
|
||||
name: "Cron Conversation".into(),
|
||||
r#type: "acp".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("finished".into()),
|
||||
source: Some("nomifun".into()),
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed a minimal `cron_jobs` row so the
|
||||
/// `conversation_artifacts.cron_job_id → cron_jobs(id)` FK is satisfied
|
||||
/// when a skill-suggest artifact is persisted (foreign_keys=ON).
|
||||
async fn seed_cron_job(pool: &SqlitePool, id: &str) {
|
||||
let repo = SqliteCronRepository::new(pool.clone());
|
||||
repo.insert(&CronJobRow {
|
||||
id: id.into(),
|
||||
name: "Test Cron".into(),
|
||||
enabled: true,
|
||||
schedule_kind: "every".into(),
|
||||
schedule_value: "60000".into(),
|
||||
schedule_tz: None,
|
||||
schedule_description: None,
|
||||
payload_message: "ping".into(),
|
||||
execution_mode: "new_conversation".into(),
|
||||
agent_config: None,
|
||||
conversation_id: None,
|
||||
conversation_title: None,
|
||||
agent_type: "acp".into(),
|
||||
created_by: "user".into(),
|
||||
skill_content: None,
|
||||
description: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
target_kind: "agent".into(),
|
||||
terminal_mode: None,
|
||||
terminal_session_id: None,
|
||||
terminal_command: None,
|
||||
terminal_args: None,
|
||||
terminal_script: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_skill_suggest_when_file_is_valid() {
|
||||
let temp = tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::write(
|
||||
workspace.join(SKILL_SUGGEST_FILENAME),
|
||||
"---\nname: daily-report\ndescription: Daily report\n---\n\nCheck sources.\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IConversationRepository> = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
seed_cron_job(db.pool(), "cron-1").await;
|
||||
repo.create(&make_conversation("1")).await.unwrap();
|
||||
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
let detector = SkillSuggestDetector::new(bus.clone(), repo.clone(), temp.path().to_path_buf());
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
let emitted = detector
|
||||
.check_and_emit("1", "cron-1", &workspace.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(emitted);
|
||||
let msg = rx.try_recv().unwrap();
|
||||
assert_eq!(msg.name, "conversation.artifact");
|
||||
assert_eq!(msg.data["kind"], "skill_suggest");
|
||||
assert_eq!(msg.data["status"], "pending");
|
||||
assert_eq!(msg.data["payload"]["cron_job_id"], "cron-1");
|
||||
assert_eq!(msg.data["payload"]["name"], "daily-report");
|
||||
|
||||
let rows = repo.list_artifacts(1).await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].kind, "skill_suggest");
|
||||
assert_eq!(rows[0].status, "pending");
|
||||
assert!(rows[0].payload.contains("\"skillContent\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suppresses_duplicate_skill_suggest_content() {
|
||||
let temp = tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::write(
|
||||
workspace.join(SKILL_SUGGEST_FILENAME),
|
||||
"---\nname: daily-report\ndescription: Daily report\n---\n\nCheck sources.\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IConversationRepository> = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
seed_cron_job(db.pool(), "cron-1").await;
|
||||
repo.create(&make_conversation("1")).await.unwrap();
|
||||
repo.create(&make_conversation("conv-2")).await.unwrap();
|
||||
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
let detector = SkillSuggestDetector::new(bus.clone(), repo, temp.path().to_path_buf());
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
assert!(
|
||||
detector
|
||||
.check_and_emit("1", "cron-1", &workspace.to_string_lossy())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(rx.try_recv().is_ok());
|
||||
|
||||
assert!(
|
||||
detector
|
||||
.check_and_emit("conv-2", "cron-1", &workspace.to_string_lossy())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suppresses_skill_suggest_when_saved_skill_exists() {
|
||||
let temp = tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
tokio::fs::create_dir_all(&workspace).await.unwrap();
|
||||
tokio::fs::write(
|
||||
workspace.join(SKILL_SUGGEST_FILENAME),
|
||||
"---\nname: daily-report\ndescription: Daily report\n---\n\nCheck sources.\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let skill_dir = temp.path().join("cron/skills/cron-cron-1");
|
||||
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: saved-skill\ndescription: Saved skill\n---\n\nDo the task.\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IConversationRepository> = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
repo.create(&make_conversation("1")).await.unwrap();
|
||||
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
let detector = SkillSuggestDetector::new(bus.clone(), repo, temp.path().to_path_buf());
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
let emitted = detector
|
||||
.check_and_emit("1", "cron-1", &workspace.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(emitted);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_conversation::ConversationService;
|
||||
|
||||
use crate::service::CronService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CronRouterState {
|
||||
pub cron_service: Arc<CronService>,
|
||||
pub conversation_service: ConversationService,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
use nomifun_cron::prompt::{
|
||||
SKILL_SUGGEST_FILENAME, build_existing_conversation_prompt,
|
||||
build_new_conversation_prompt_with_skill_suggest, build_new_conversation_with_skill_prompt,
|
||||
build_skill_suggest_prompt,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn build_new_conversation_prompt_with_skill_suggest_includes_follow_up_block() {
|
||||
let prompt = build_new_conversation_prompt_with_skill_suggest("Daily Report", "Every day at 9am", "Summarize it.");
|
||||
assert!(prompt.contains(&format!("create a file named \"{SKILL_SUGGEST_FILENAME}\"")));
|
||||
assert!(prompt.contains("short kebab-case name"));
|
||||
assert!(prompt.contains("If you think the task is too simple or one-off to benefit from a skill file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_new_conversation_with_skill_prompt_matches_frontend_copy() {
|
||||
let prompt = build_new_conversation_with_skill_prompt("Daily Report", "Summarize it.");
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"[Scheduled Task Context]\nTask: Daily Report\n\nThis is a scheduled task execution. A skill file with detailed instructions has been loaded\ninto your workspace. You MUST read and follow the skill instructions precisely.\n\nRules:\n1. Execute the task directly — do NOT ask clarifying questions.\n2. Follow the output format, tone, sources, and steps defined in the skill.\n3. If the task requires external data (news, weather, etc.), search for the latest information.\n[/Scheduled Task Context]\n\nSummarize it."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_existing_conversation_prompt_matches_frontend_copy() {
|
||||
let prompt = build_existing_conversation_prompt("Daily Report", "Every day at 9am", "Summarize it.");
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"[Scheduled Task Execution]\nTask: Daily Report\nSchedule: Every day at 9am\n\nThis message is NOT a conversation from the user — it is a scheduled task triggered automatically.\nThe text below is a TASK INSTRUCTION that you must execute, not something the user is saying to you.\n\nRules:\n1. Treat the instruction as a command to perform, not as a chat message to respond to.\n2. Execute it directly — do NOT ask clarifying questions.\n3. If the task requires external data (news, weather, etc.), search for the latest information.\n\nTask instruction:\nSummarize it."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_skill_suggest_prompt_matches_frontend_copy() {
|
||||
let prompt = build_skill_suggest_prompt("Daily Report");
|
||||
assert!(prompt.starts_with("The task \"Daily Report\" is a recurring scheduled task. Based on what you just did,"));
|
||||
assert!(prompt.contains("```markdown"));
|
||||
assert!(prompt.contains("Use concrete details from this execution, not placeholders."));
|
||||
assert!(
|
||||
prompt.ends_with(
|
||||
"If you think the task is too simple or one-off to benefit from a skill file, you can skip this."
|
||||
)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use nomifun_cron::skill_file::{
|
||||
ParsedSkillContent, build_skill_content, content_hash, cron_skill_dir, cron_skill_file_path, parse_skill_content,
|
||||
read_skill_content, validate_skill_content, write_raw_skill_file, write_skill_file,
|
||||
};
|
||||
|
||||
fn unique_temp_dir(label: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("nomifun-cron-{label}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_skill_content_matches_frontend_shape() {
|
||||
let content = build_skill_content(
|
||||
"Daily Report",
|
||||
"Line 1\nLine 2\r\nLine 3",
|
||||
"Run report",
|
||||
Some("Every day at 9am"),
|
||||
);
|
||||
|
||||
assert!(content.contains("name: Daily Report"));
|
||||
assert!(content.contains("description: Line 1 Line 2 Line 3"));
|
||||
assert!(content.contains("This is a scheduled task: **Daily Report**"));
|
||||
assert!(content.contains("Schedule: Every day at 9am"));
|
||||
assert!(content.contains("## Instructions"));
|
||||
assert!(content.ends_with("Run report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_content_roundtrips_built_files() {
|
||||
let built = build_skill_content("My Job", "My Description", "First\n\nSecond", None);
|
||||
let parsed = parse_skill_content(&built).unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ParsedSkillContent {
|
||||
name: "My Job".into(),
|
||||
description: "My Description".into(),
|
||||
body: "First\n\nSecond".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_content_skips_blank_lines_after_frontmatter() {
|
||||
let parsed = parse_skill_content("---\nname: Test\ndescription: Desc\n---\n\n\nPrompt").unwrap();
|
||||
assert_eq!(parsed.body, "Prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skill_content_handles_empty_body() {
|
||||
let parsed = parse_skill_content("---\nname: Test\ndescription: Desc\n---\n\n").unwrap();
|
||||
assert_eq!(parsed.body, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_content_rejects_placeholders() {
|
||||
let err =
|
||||
validate_skill_content("---\nname: skill-name\ndescription: Real description\n---\n\nReal body").unwrap_err();
|
||||
assert!(err.to_string().contains("template placeholder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_hash_normalizes_line_endings_and_edges() {
|
||||
let a = content_hash("---\nname: Test\ndescription: Desc\n---\n\nBody\n");
|
||||
let b = content_hash("---\r\nname: Test\r\ndescription: Desc\r\n---\r\n\r\nBody");
|
||||
let c = content_hash(" ---\nname: Test\ndescription: Desc\n---\n\nBody ");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a, c);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_read_and_resolve_skill_file_paths() {
|
||||
let base = unique_temp_dir("write-read");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let file_path = write_skill_file(
|
||||
&base,
|
||||
"job-123",
|
||||
"Daily Report",
|
||||
"Generate daily report",
|
||||
"Run report",
|
||||
Some("Every day at 9am"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cron_skill_dir(&base, "job-123").unwrap(),
|
||||
base.join("cron").join("skills").join("cron-job-123")
|
||||
);
|
||||
assert_eq!(file_path, cron_skill_file_path(&base, "job-123").unwrap());
|
||||
|
||||
let raw = read_skill_content(&base, "job-123").await.unwrap().unwrap();
|
||||
let parsed = parse_skill_content(&raw).unwrap();
|
||||
assert_eq!(parsed.name, "Daily Report");
|
||||
assert_eq!(parsed.description, "Generate daily report");
|
||||
assert_eq!(parsed.body, "Run report");
|
||||
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_raw_skill_file_validates_before_writing() {
|
||||
let base = unique_temp_dir("write-raw");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let err = write_raw_skill_file(&base, "job-456", "not valid").await.unwrap_err();
|
||||
assert!(err.to_string().contains("skill file must start with YAML frontmatter"));
|
||||
assert!(read_skill_content(&base, "job-456").await.unwrap().is_none());
|
||||
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user