Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/core/dispatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "core/agent.h"
#include "core/bootstrap.h"
#include "core/memory.h"
#include "tools/cron.h"
#include <stdio.h>
#include <string.h>

Expand Down Expand Up @@ -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;
}
23 changes: 23 additions & 0 deletions src/core/memory.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/core/memory.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
40 changes: 29 additions & 11 deletions src/tools/cron.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
{
Expand Down
9 changes: 9 additions & 0 deletions src/tools/cron.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cron.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include "tools/cron.h"
#include "core/memory.h"
#include "channels/channel.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -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());
Expand All @@ -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;
}
Loading