645 lines
30 KiB
C
645 lines
30 KiB
C
#include "trainlog/web_programs.h"
|
|
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include <openssl/evp.h>
|
|
#include <sqlite3.h>
|
|
#include <yyjson.h>
|
|
|
|
#include "database_internal.h"
|
|
#include "trainlog/id.h"
|
|
|
|
typedef struct PlanningRow {
|
|
const char *id;
|
|
const char *original;
|
|
const char *current;
|
|
const char *execution;
|
|
const char *planning;
|
|
const char *assigned;
|
|
bool cede;
|
|
} PlanningRow;
|
|
|
|
static TrainlogStatus
|
|
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
|
|
size_t length = strlen(reason) + 16U;
|
|
*output = malloc(length);
|
|
if (*output == NULL) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
|
|
return status;
|
|
}
|
|
|
|
static bool planning_digest(
|
|
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
|
|
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
|
unsigned char digest[32];
|
|
unsigned int length = 0U;
|
|
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
|
EVP_DigestUpdate(context, first, first_size) == 1 &&
|
|
EVP_DigestUpdate(context, second, second_size) == 1 &&
|
|
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
|
|
EVP_MD_CTX_free(context);
|
|
if (!okay) {
|
|
return false;
|
|
}
|
|
for (size_t index = 0U; index < 32U; ++index) {
|
|
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
|
|
}
|
|
output[64] = '\0';
|
|
return true;
|
|
}
|
|
|
|
static bool valid_civil_date(const char *value) {
|
|
unsigned year, month, day;
|
|
int days;
|
|
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
|
|
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
|
|
month < 1U || month > 12U) {
|
|
return false;
|
|
}
|
|
for (size_t index = 0U; index < 10U; ++index) {
|
|
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
|
|
return false;
|
|
}
|
|
}
|
|
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
|
days = MONTH_DAYS[month - 1U];
|
|
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
|
|
days = 29;
|
|
}
|
|
return day >= 1U && day <= (unsigned)days;
|
|
}
|
|
|
|
static bool
|
|
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
|
|
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
|
|
: yyjson_mut_obj_add_strcpy(document, object, key, value);
|
|
}
|
|
|
|
static bool request_valid(yyjson_val *root, bool commit) {
|
|
yyjson_obj_iter iterator;
|
|
yyjson_val *key;
|
|
size_t count = 0U;
|
|
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
|
|
return false;
|
|
}
|
|
iterator = yyjson_obj_iter_with(root);
|
|
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
|
const char *name = yyjson_get_str(key);
|
|
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
|
|
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
|
|
strcmp(name, "expected_revision") != 0 &&
|
|
!(commit && strcmp(name, "preview_sha256") == 0)) {
|
|
return false;
|
|
}
|
|
++count;
|
|
}
|
|
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
|
|
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
|
|
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
|
|
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
|
|
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
|
|
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
|
|
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
|
|
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
|
|
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
|
|
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
|
|
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
|
|
}
|
|
|
|
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
bool present = true;
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"SELECT 1 FROM session_preparations WHERE "
|
|
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
|
|
-1,
|
|
&statement,
|
|
NULL) == SQLITE_OK &&
|
|
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
|
|
present = sqlite3_step(statement) != SQLITE_DONE;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
return present;
|
|
}
|
|
|
|
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
|
|
static const char SQL[] =
|
|
"SELECT EXISTS(SELECT 1 FROM "
|
|
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
|
|
"WHERE producer_kind='desktop') peer WHERE "
|
|
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
|
|
"JOIN sync_generation_artifacts artifact "
|
|
"ON artifact.generation_id=generation.generation_id "
|
|
"WHERE generation.generation_id=(SELECT latest.generation_id "
|
|
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
|
|
"AND latest.producer_kind='desktop' "
|
|
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
|
|
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
|
|
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
|
|
sqlite3_step(statement) == SQLITE_ROW) {
|
|
status =
|
|
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus build_preview(TrainlogDatabase *database,
|
|
const char *program_id,
|
|
yyjson_val *request,
|
|
char **output,
|
|
size_t *output_size) {
|
|
char *detail = NULL;
|
|
size_t detail_size = 0U;
|
|
yyjson_doc *source = NULL;
|
|
yyjson_mut_doc *response = NULL;
|
|
yyjson_val *program;
|
|
yyjson_val *sessions;
|
|
yyjson_val *ceded;
|
|
yyjson_mut_val *root;
|
|
yyjson_mut_val *moves;
|
|
PlanningRow rows[64] = {0};
|
|
const char *slots[64] = {0};
|
|
size_t first = 64U;
|
|
size_t last = 64U;
|
|
size_t count;
|
|
size_t slot_count = 0U;
|
|
size_t next_slot = 0U;
|
|
char fingerprint[65];
|
|
char *choices = NULL;
|
|
size_t choices_size = 0U;
|
|
TrainlogStatus status =
|
|
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
|
program = source == NULL ? NULL : yyjson_doc_get_root(source);
|
|
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
|
|
ceded = yyjson_obj_get(request, "ceded_session_ids");
|
|
count = yyjson_arr_size(sessions);
|
|
if (program == NULL || count > 64U ||
|
|
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
|
|
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
|
|
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
|
|
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
|
|
status = planning_error(
|
|
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
goto done;
|
|
}
|
|
for (size_t index = 0U; index < count; ++index) {
|
|
yyjson_val *session = yyjson_arr_get(sessions, index);
|
|
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
|
|
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
|
|
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
|
|
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
|
|
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
|
|
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
|
|
0) {
|
|
first = index;
|
|
}
|
|
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
|
|
0) {
|
|
last = index;
|
|
}
|
|
}
|
|
if (first >= count || last >= count || first > last ||
|
|
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
|
|
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
|
status = planning_error(
|
|
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
goto done;
|
|
}
|
|
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
|
yyjson_val *id = yyjson_arr_get(ceded, index);
|
|
bool matched = false;
|
|
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
|
|
status = planning_error(
|
|
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
goto done;
|
|
}
|
|
for (size_t candidate = first; candidate <= last; ++candidate) {
|
|
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
|
|
rows[candidate].cede = true;
|
|
matched = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!matched) {
|
|
status = planning_error(
|
|
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
|
goto done;
|
|
}
|
|
}
|
|
for (size_t index = first; index <= last; ++index) {
|
|
if (strcmp(rows[index].execution, "todo") != 0 ||
|
|
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
|
|
!valid_civil_date(rows[index].original)) {
|
|
status = planning_error(
|
|
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
goto done;
|
|
}
|
|
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
|
|
status = planning_error(
|
|
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
|
goto done;
|
|
}
|
|
// WHY: a second explicit decision starts from the durable current
|
|
// schedule. A ceded identity has no current date, so its original slot
|
|
// is available only when the user selects that identity again.
|
|
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
|
|
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
|
|
slots[slot_count++] = slot;
|
|
}
|
|
}
|
|
// Selected Program slots define permitted days; no rest day is synthesized.
|
|
// Position order may differ from dates, so sort candidate dates explicitly.
|
|
for (size_t outer = 0U; outer < slot_count; ++outer) {
|
|
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
|
|
if (strcmp(slots[inner], slots[outer]) < 0) {
|
|
const char *swap = slots[outer];
|
|
slots[outer] = slots[inner];
|
|
slots[inner] = swap;
|
|
}
|
|
}
|
|
}
|
|
// A previously ceded original day may now be occupied by an active
|
|
// session. It remains one calendar slot for this operation.
|
|
size_t unique_slots = 0U;
|
|
for (size_t index = 0U; index < slot_count; ++index) {
|
|
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
|
|
slots[unique_slots++] = slots[index];
|
|
}
|
|
}
|
|
slot_count = unique_slots;
|
|
for (size_t index = first; index <= last; ++index) {
|
|
if (rows[index].cede) {
|
|
continue;
|
|
}
|
|
if (next_slot >= slot_count ||
|
|
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
|
status = planning_error("insufficient_or_duplicate_slots",
|
|
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
|
output,
|
|
output_size);
|
|
goto done;
|
|
}
|
|
rows[index].assigned = slots[next_slot++];
|
|
}
|
|
// CONTRACT: the choice digest includes stable identities in their supplied
|
|
// order. The fact digest includes the complete current Program projection.
|
|
{
|
|
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *choice_root =
|
|
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
|
|
if (choice_document == NULL || choice_root == NULL) {
|
|
yyjson_mut_doc_free(choice_document);
|
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
goto done;
|
|
}
|
|
yyjson_mut_doc_set_root(choice_document, choice_root);
|
|
(void)yyjson_mut_obj_add_strcpy(
|
|
choice_document,
|
|
choice_root,
|
|
"start_session_id",
|
|
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
|
|
(void)yyjson_mut_obj_add_strcpy(
|
|
choice_document,
|
|
choice_root,
|
|
"through_session_id",
|
|
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
|
|
(void)yyjson_mut_obj_add_strcpy(choice_document,
|
|
choice_root,
|
|
"start_date",
|
|
yyjson_get_str(yyjson_obj_get(request, "start_date")));
|
|
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
|
|
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
|
(void)yyjson_mut_arr_add_strcpy(
|
|
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
|
|
}
|
|
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
|
|
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
|
|
yyjson_mut_doc_free(choice_document);
|
|
}
|
|
if (choices == NULL ||
|
|
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
|
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
goto done;
|
|
}
|
|
response = yyjson_mut_doc_new(NULL);
|
|
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
|
moves = response == NULL ? NULL : yyjson_mut_arr(response);
|
|
if (response == NULL || root == NULL || moves == NULL) {
|
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
goto done;
|
|
}
|
|
yyjson_mut_doc_set_root(response, root);
|
|
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
|
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
|
(void)yyjson_mut_obj_add_strcpy(
|
|
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
|
|
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
|
|
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
|
|
for (size_t index = first; index <= last; ++index) {
|
|
yyjson_mut_val *move = yyjson_mut_obj(response);
|
|
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
|
|
(void)add_nullable(response, move, "original_for", rows[index].original);
|
|
(void)add_nullable(response, move, "old_for", rows[index].current);
|
|
(void)add_nullable(response, move, "new_for", rows[index].assigned);
|
|
const char *change =
|
|
rows[index].cede ? "ceded"
|
|
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
|
|
? "unchanged"
|
|
: "rescheduled";
|
|
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
|
|
(void)yyjson_mut_arr_add_val(moves, move);
|
|
}
|
|
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
|
|
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
|
done:
|
|
free(choices);
|
|
yyjson_mut_doc_free(response);
|
|
yyjson_doc_free(source);
|
|
free(detail);
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus apply_preview(TrainlogDatabase *database,
|
|
const char *operation_id,
|
|
const char *program_id,
|
|
const char *expected_revision,
|
|
const char *request_digest,
|
|
const char *preview,
|
|
char **output,
|
|
size_t *output_size) {
|
|
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
|
|
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
|
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
|
|
sqlite3_stmt *statement = NULL;
|
|
char revision[80];
|
|
char now[32];
|
|
time_t clock_now = time(NULL);
|
|
struct tm utc;
|
|
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
|
|
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
|
|
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
|
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
goto done;
|
|
}
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
|
|
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
|
status = TRAINLOG_STATUS_CONFLICT;
|
|
goto done;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO program_session_planning "
|
|
"(program_session_id,current_for,state,operation_id,updated_at) "
|
|
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
|
|
"current_for=excluded.current_for,state=excluded.state,"
|
|
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK) {
|
|
goto done;
|
|
}
|
|
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
|
|
yyjson_val *move = yyjson_arr_get(moves, index);
|
|
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
|
|
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
|
|
continue;
|
|
}
|
|
sqlite3_reset(statement);
|
|
sqlite3_clear_bindings(statement);
|
|
if (sqlite3_bind_text(statement,
|
|
1,
|
|
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
|
|
-1,
|
|
SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
(yyjson_is_null(new_for)
|
|
? sqlite3_bind_null(statement, 2)
|
|
: sqlite3_bind_text(
|
|
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement,
|
|
3,
|
|
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
|
|
: "active",
|
|
-1,
|
|
SQLITE_STATIC) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE) {
|
|
goto done;
|
|
}
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
{
|
|
size_t length = strlen(preview) + strlen(revision) + 52U;
|
|
*output = malloc(length);
|
|
if (*output == NULL) {
|
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
goto done;
|
|
}
|
|
*output_size = (size_t)snprintf(*output,
|
|
length,
|
|
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
|
|
(int)strlen(preview) - 1,
|
|
preview,
|
|
revision);
|
|
}
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE) {
|
|
goto done;
|
|
}
|
|
status = TRAINLOG_STATUS_OK;
|
|
done:
|
|
(void)sqlite3_finalize(statement);
|
|
yyjson_doc_free(document);
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
|
const char *program_id,
|
|
const char *operation_id,
|
|
const char *body,
|
|
size_t body_size,
|
|
bool commit,
|
|
char **output_json,
|
|
size_t *output_size) {
|
|
yyjson_doc *request_doc = NULL;
|
|
yyjson_val *request;
|
|
sqlite3_stmt *statement = NULL;
|
|
char request_digest[65];
|
|
char *preview = NULL;
|
|
size_t preview_size = 0U;
|
|
TrainlogStatus status;
|
|
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
|
|
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
|
|
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
*output_json = NULL;
|
|
*output_size = 0U;
|
|
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
|
|
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
|
|
if (!request_valid(request, commit) ||
|
|
!planning_digest(body, body_size, "", 0U, request_digest)) {
|
|
yyjson_doc_free(request_doc);
|
|
return planning_error("invalid_reschedule_request",
|
|
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
|
output_json,
|
|
output_size);
|
|
}
|
|
if (sqlite3_exec(
|
|
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
|
|
SQLITE_OK) {
|
|
yyjson_doc_free(request_doc);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (commit) {
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"SELECT program_id,request_sha256,response_json FROM "
|
|
"program_reschedule_operations WHERE operation_id=?",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto finish;
|
|
}
|
|
if (sqlite3_step(statement) == SQLITE_ROW) {
|
|
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
|
|
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
|
|
const char *saved = (const char *)sqlite3_column_text(statement, 2);
|
|
if (known_program != NULL && known_digest != NULL && saved != NULL &&
|
|
strcmp(known_program, program_id) == 0 &&
|
|
strcmp(known_digest, request_digest) == 0) {
|
|
*output_json = strdup(saved);
|
|
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
|
|
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
|
} else {
|
|
status = planning_error("operation_identity_conflict",
|
|
TRAINLOG_STATUS_CONFLICT,
|
|
output_json,
|
|
output_size);
|
|
}
|
|
goto finish;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"SELECT 1 FROM program_requests WHERE request_id=? "
|
|
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto finish;
|
|
}
|
|
int cross_command = sqlite3_step(statement);
|
|
(void)sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (cross_command != SQLITE_DONE) {
|
|
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
|
|
TRAINLOG_STATUS_CONFLICT,
|
|
output_json,
|
|
output_size)
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto finish;
|
|
}
|
|
// WHY: the desktop's last imported draft snapshot cannot prove that
|
|
// Android stayed idle while offline. A recent acknowledged V2 Program
|
|
// generation is required; Android independently rejects a changed
|
|
// schedule if a Program draft starts after that acknowledgement.
|
|
status = peer_planning_fresh(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
if (status == TRAINLOG_STATUS_CONFLICT) {
|
|
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
|
|
}
|
|
goto finish;
|
|
}
|
|
}
|
|
status = build_preview(database, program_id, request, &preview, &preview_size);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
if (preview != NULL) {
|
|
*output_json = preview;
|
|
*output_size = preview_size;
|
|
preview = NULL;
|
|
}
|
|
goto finish;
|
|
}
|
|
if (!commit) {
|
|
*output_json = preview;
|
|
*output_size = preview_size;
|
|
preview = NULL;
|
|
goto finish;
|
|
}
|
|
{
|
|
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
|
|
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
|
|
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
|
|
if (fingerprint == NULL ||
|
|
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
|
|
status = planning_error(
|
|
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
|
|
} else {
|
|
status = apply_preview(database,
|
|
operation_id,
|
|
program_id,
|
|
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
|
|
request_digest,
|
|
preview,
|
|
output_json,
|
|
output_size);
|
|
}
|
|
yyjson_doc_free(preview_doc);
|
|
}
|
|
finish:
|
|
(void)sqlite3_finalize(statement);
|
|
free(preview);
|
|
yyjson_doc_free(request_doc);
|
|
if (status == TRAINLOG_STATUS_OK && commit) {
|
|
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
|
return status;
|
|
}
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
|
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
|
|
return planning_error("reschedule_unavailable", status, output_json, output_size);
|
|
}
|
|
return status;
|
|
}
|