diff --git a/src/core/dispatch.c b/src/core/dispatch.c index 287cb91..fc8a1b6 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -8,6 +8,7 @@ #include "core/agent.h" #include "core/bootstrap.h" #include "core/memory.h" +#include "tools/cron.h" #include #include @@ -43,5 +44,8 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) resp_buf, sizeof(resp_buf)); if (err != 0 && resp_buf[0] == '\0') snprintf(resp_buf, sizeof(resp_buf), "Error: agent failed (code %d)", err); - return ch->send(msg->session_id, resp_buf, NULL, 0); + int send_err = ch->send(msg->session_id, resp_buf, NULL, 0); + if (send_err == 0 && err == 0 && ch->name && strcmp(ch->name, "cron") == 0 && msg->user_id) + cron_ack_delivery(msg->user_id); + return send_err; } diff --git a/src/core/memory.c b/src/core/memory.c index cb763ab..9b505da 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -413,6 +413,29 @@ int cron_job_list(cron_job_row_t *out, int max_count) return count; } +int cron_job_get_by_id(const char *id, cron_job_row_t *out) +{ + if (!g_db || !id || !out) return -1; + const char *sql = "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs " + "WHERE id = ?1 LIMIT 1"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT); + int ret = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) { + copy_str_bounded(out->id, sizeof(out->id), (const char *)sqlite3_column_text(stmt, 0)); + copy_str_bounded(out->schedule, sizeof(out->schedule), (const char *)sqlite3_column_text(stmt, 1)); + copy_str_bounded(out->message, sizeof(out->message), (const char *)sqlite3_column_text(stmt, 2)); + copy_str_bounded(out->channel, sizeof(out->channel), (const char *)sqlite3_column_text(stmt, 3)); + copy_str_bounded(out->recipient, sizeof(out->recipient), (const char *)sqlite3_column_text(stmt, 4)); + out->next_run = sqlite3_column_int64(stmt, 5); + out->enabled = sqlite3_column_int(stmt, 6); + ret = 1; + } + sqlite3_finalize(stmt); + return ret; +} + int cron_job_get_next_due(long long now, cron_job_row_t *out) { if (!g_db || !out) return -1; diff --git a/src/core/memory.h b/src/core/memory.h index 44aac17..d7631d5 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -155,6 +155,15 @@ int cron_job_list(cron_job_row_t *out, int max_count); */ int cron_job_get_next_due(long long now, cron_job_row_t *out); +/** + * Load a cron job by id. + * + * @param id Job id. + * @param out Filled with job data if found. + * @return 1 if found, 0 if missing, -1 on error. + */ +int cron_job_get_by_id(const char *id, cron_job_row_t *out); + /** * Release resources and close the database. Safe to call multiple times. */ diff --git a/src/tools/cron.c b/src/tools/cron.c index eb46e18..d934d68 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -171,6 +171,13 @@ static int cron_init(const config_t *cfg) return 0; } +static char *cron_strdup_checked(const char *src) +{ + if (!src) return NULL; + char *copy = strdup(src); + return copy; +} + static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) { if (!out) return -1; @@ -179,27 +186,38 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) cron_job_row_t row; memset(&row, 0, sizeof(row)); if (cron_job_get_next_due(now, &row) != 1) return 0; - int is_one_shot = cron_is_one_shot(row.schedule); - if (is_one_shot) { - cron_job_delete(row.id); - } else { - long long next = 0; - if (cron_parse_next_run(row.schedule, now, &next) == 0) - cron_job_update_next_run(row.id, next); - } memset(out, 0, sizeof(*out)); char session_id[256]; snprintf(session_id, sizeof(session_id), "%s:%s", row.channel[0] ? row.channel : "cli", row.recipient[0] ? row.recipient : "default"); - out->session_id = strdup(session_id); - out->user_id = strdup(row.id); - out->text = strdup(row.message); + out->session_id = cron_strdup_checked(session_id); + out->user_id = cron_strdup_checked(row.id); + out->text = cron_strdup_checked(row.message); out->attachments = NULL; out->attachments_count = 0; + if (!out->session_id || !out->user_id || !out->text) { + channel_incoming_msg_clear(out); + return -1; + } return 1; } +int cron_ack_delivery(const char *job_id) +{ + if (!job_id || !job_id[0]) return -1; + cron_job_row_t row; + memset(&row, 0, sizeof(row)); + if (cron_job_get_by_id(job_id, &row) != 1) return -1; + if (cron_is_one_shot(row.schedule)) + return cron_job_delete(row.id); + long long now = (long long)time(NULL); + long long next = 0; + if (cron_parse_next_run(row.schedule, now, &next) != 0) + return -1; + return cron_job_update_next_run(row.id, next); +} + static int cron_send(const char *recipient, const char *text, const channel_attachment_t *attachments, size_t att_count) { diff --git a/src/tools/cron.h b/src/tools/cron.h index 1635d89..3e48e64 100644 --- a/src/tools/cron.h +++ b/src/tools/cron.h @@ -41,6 +41,15 @@ const channel_t *channel_cron_get(void); /** Get the cron tool for agent (list, create, delete, toggle jobs). */ const tool_t *tool_cron_get(void); +/** + * Commit cron delivery after the agent successfully handles a fired job. + * One-shot jobs are deleted; recurring jobs advance next_run. + * + * @param job_id Job id from cron poll user_id field. + * @return 0 on success, non-zero on error. + */ +int cron_ack_delivery(const char *job_id); + /** * Create a cron job (shared by tool and HTTP handler). * If id_out[0] == '\0', a random ID is generated. diff --git a/tests/test_cron.c b/tests/test_cron.c index ea21c0c..75b50a3 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -5,6 +5,7 @@ #include "tools/cron.h" #include "core/memory.h" +#include "channels/channel.h" #include #include #include @@ -139,6 +140,55 @@ static int test_one_shot_detection(void) return 0; } +static int test_cron_ack_delivery_deferred(void) +{ + const char *path = "/tmp/shellclaw_test_cron_ack.db"; + remove(path); + ASSERT(memory_init(path) == 0); + long long now = (long long)time(NULL); + long long due_at = now - 10; + ASSERT(cron_job_create("oneshot_ack", "at:9999999999", "Fire me", "cli", "default", due_at, 1) == 0); + cron_job_row_t row; + ASSERT(cron_job_get_next_due(now, &row) == 1); + ASSERT(strcmp(row.id, "oneshot_ack") == 0); + ASSERT(cron_job_get_by_id("oneshot_ack", &row) == 1); + ASSERT(cron_ack_delivery("oneshot_ack") == 0); + ASSERT(cron_job_get_by_id("oneshot_ack", &row) == 0); + ASSERT(cron_job_create("interval_ack", "interval:3600", "Repeat", "cli", "default", due_at, 1) == 0); + ASSERT(cron_job_get_next_due(now, &row) == 1); + long long before_ack = row.next_run; + ASSERT(cron_ack_delivery("interval_ack") == 0); + ASSERT(cron_job_get_by_id("interval_ack", &row) == 1); + ASSERT(row.next_run > before_ack); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_cron_poll_keeps_job_until_ack(void) +{ + const channel_t *cron_ch = channel_cron_get(); + ASSERT(cron_ch != NULL); + const char *path = "/tmp/shellclaw_test_cron_poll.db"; + remove(path); + ASSERT(memory_init(path) == 0); + long long now = (long long)time(NULL); + ASSERT(cron_job_create("poll_keep", "at:9999999999", "Due now", "cli", "default", now - 1, 1) == 0); + channel_incoming_msg_t msg; + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 1); + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "poll_keep") == 0); + cron_job_row_t row; + ASSERT(cron_job_get_by_id("poll_keep", &row) == 1); + channel_incoming_msg_clear(&msg); + ASSERT(cron_ack_delivery("poll_keep") == 0); + ASSERT(cron_job_get_by_id("poll_keep", &row) == 0); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -149,6 +199,8 @@ int main(void) RUN(test_cron_job_crud_and_due()); RUN(test_cron_tool_execute()); RUN(test_one_shot_detection()); + RUN(test_cron_ack_delivery_deferred()); + RUN(test_cron_poll_keeps_job_until_ack()); printf("test_cron: all tests passed\n"); return 0; }