Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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();
}