/** * @file sync.c * @brief Shared direct-MTP Trainlog synchronization engine. */ #include "trainlog/sync.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "trainlog/id.h" #include "trainlog/sync_history.h" #include "trainlog/timeutil.h" #define SYNC_DEVICE_CAPACITY 8U #define SYNC_STORAGE_CAPACITY 8U #define SYNC_ENTRY_CAPACITY 256U #define SYNC_TOOL_OUTPUT_MAX 4095U #define SYNC_REQUEST_TEXT_MAX 4095U static const char *const MOBILE_EXPORT_NAME = "trainlog-mobile-export-v3.json"; static const char *const MOBILE_EXPORT_V2_NAME = "trainlog-mobile-export-v2.json"; static const char *const MOBILE_EXPORT_V1_NAME = "trainlog-mobile-export-v1.json"; static const char *const PC_MOBILE_EXPORT_NAME = "trainlog-pc-mobile-export-v3.json"; static const char *const PC_CATALOG_NAME = "trainlog-pc-catalog-v1.json"; static const char *const EQUIPMENT_ASSOCIATIONS_NAME = "trainlog-equipment-associations-v2.json"; static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME = "trainlog-mobile-equipment-definitions-v1.json"; static const char *const PC_EQUIPMENT_DEFINITIONS_NAME = "trainlog-pc-equipment-definitions-v1.json"; static const char *const EXERCISE_BODY_ZONES_NAME = "trainlog-exercise-body-zones-v1.json"; static const char *const EXERCISE_ALIASES_NAME = "trainlog-exercise-aliases-v1.json"; static const char *const TRAINING_FEEDBACK_NAME = "trainlog-training-feedback-v2.json"; static const char *const TRAINING_FEEDBACK_V1_NAME = "trainlog-training-feedback-v1.json"; static const char *const EXERCISE_PROFILE_STATE_NAME = "trainlog-exercise-profile-state-v1.json"; static const char *const AI_SESSION_DRAFTS_NAME = "trainlog-ai-session-drafts-v1.json"; static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; static const char *const SYNC_RECEIPT_NAME = "trainlog-sync-receipt-v1.json"; static const char *const MOBILE_EXPORT_LOCAL = "/tmp/trainlog-mobile-export-v3.json"; static const char *const PC_MOBILE_EXPORT_LOCAL = "/tmp/trainlog-pc-mobile-export-v3.json"; static const char *const PC_CATALOG_LOCAL = "/tmp/trainlog-pc-catalog-v1.json"; static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL = "/tmp/trainlog-equipment-associations-v2.json"; static const char *const MOBILE_EQUIPMENT_DEFINITIONS_LOCAL = "/tmp/trainlog-mobile-equipment-definitions-v1.json"; static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL = "/tmp/trainlog-pc-equipment-definitions-v1.json"; static const char *const EQUIPMENT_DEFINITIONS_RESULT = "/tmp/trainlog-equipment-definitions-result.txt"; static const char *const EXERCISE_BODY_ZONES_LOCAL = "/tmp/trainlog-exercise-body-zones-v1.json"; static const char *const EXERCISE_ALIASES_LOCAL = "/tmp/trainlog-exercise-aliases-v1.json"; static const char *const TRAINING_FEEDBACK_LOCAL = "/tmp/trainlog-training-feedback-v2.json"; static const char *const EXERCISE_PROFILE_STATE_LOCAL = "/tmp/trainlog-exercise-profile-state-v1.json"; static const char *const AI_SESSION_DRAFTS_LOCAL = "/tmp/trainlog-ai-session-drafts-v1.json"; static const char *const EXERCISE_BODY_ZONES_RESULT = "/tmp/trainlog-exercise-body-zones-result.txt"; static const char *const EXERCISE_ALIASES_RESULT = "/tmp/trainlog-exercise-aliases-result.txt"; static const char *const TRAINING_FEEDBACK_RESULT = "/tmp/trainlog-training-feedback-result.txt"; static const char *const EXERCISE_PROFILE_STATE_RESULT = "/tmp/trainlog-exercise-profile-state-result.txt"; static const char *const SYNC_REQUEST_LOCAL = "/tmp/trainlog-sync-request-v1.json"; static const char *const SYNC_RECEIPT_LOCAL = "/tmp/trainlog-sync-receipt-v1.json"; static const char *const MOBILE_IMPORT_RESULT = "/tmp/trainlog-mobile-import-result.txt"; static const char *const PC_CATALOG_RESULT = "/tmp/trainlog-pc-catalog-result.txt"; static const char *const EQUIPMENT_ASSOCIATIONS_RESULT = "/tmp/trainlog-equipment-associations-result.txt"; static const char *const AI_POST_SYNC_RESULT = "/tmp/trainlog-ai-post-sync-result.txt"; static const char *const AI_SESSION_DRAFT_RESULT = "/tmp/trainlog-ai-session-draft-result.txt"; typedef struct SyncSilence { int saved_stdout; int saved_stderr; int null_fd; } SyncSilence; static void sync_compose_diagnostic( char *output, size_t output_size, const char *prefix, const char *detail ) { size_t written = 0U; if (output == NULL || output_size == 0U) { return; } /* CONTRACT: diagnostics retain their specific context and as much of the * underlying failure as fits. INVARIANT: output is always NUL-terminated, * even when an external tool supplies the maximum-length error. */ while (prefix != NULL && prefix[written] != '\0' && written + 1U < output_size) { output[written] = prefix[written]; written += 1U; } if (detail != NULL) { size_t detail_index = 0U; while (detail[detail_index] != '\0' && written + 1U < output_size) { output[written] = detail[detail_index]; written += 1U; detail_index += 1U; } } output[written] = '\0'; } static const char *sync_ai_inbound_status_text(TrainlogAiInboundStatus status) { switch (status) { case TRAINLOG_AI_INBOUND_NONE: return "NONE"; case TRAINLOG_AI_INBOUND_IMPORTED: return "IMPORTED"; case TRAINLOG_AI_INBOUND_ALREADY_IMPORTED: return "ALREADY_IMPORTED"; case TRAINLOG_AI_INBOUND_REJECTED: return "REJECTED"; case TRAINLOG_AI_INBOUND_DRIVE_FAIL: return "DRIVE_FAIL"; case TRAINLOG_AI_INBOUND_ARCHIVE_FAIL: return "ARCHIVE_FAIL"; case TRAINLOG_AI_INBOUND_NOT_RUN: default: return "NOT_RUN"; } } static TrainlogAiInboundStatus sync_parse_ai_inbound_status(const char *text) { static const struct { const char *marker; TrainlogAiInboundStatus status; } values[] = { {"AI_SESSION_DRAFT_INBOUND=ALREADY_IMPORTED", TRAINLOG_AI_INBOUND_ALREADY_IMPORTED}, {"AI_SESSION_DRAFT_INBOUND=ARCHIVE_FAIL", TRAINLOG_AI_INBOUND_ARCHIVE_FAIL}, {"AI_SESSION_DRAFT_INBOUND=DRIVE_FAIL", TRAINLOG_AI_INBOUND_DRIVE_FAIL}, {"AI_SESSION_DRAFT_INBOUND=IMPORTED", TRAINLOG_AI_INBOUND_IMPORTED}, {"AI_SESSION_DRAFT_INBOUND=REJECTED", TRAINLOG_AI_INBOUND_REJECTED}, {"AI_SESSION_DRAFT_INBOUND=NONE", TRAINLOG_AI_INBOUND_NONE}, }; TrainlogAiInboundStatus result = TRAINLOG_AI_INBOUND_NOT_RUN; const char *line; size_t index; if (text == NULL) return TRAINLOG_AI_INBOUND_NOT_RUN; /* WHY: helper diagnostics are untrusted text and may quote a status marker. * CONTRACT: only a complete marker line is a helper result; CRLF is accepted. * INVARIANT: scanning through all lines makes the last valid result authoritative. */ line = text; while (*line != '\0') { const char *newline = strchr(line, '\n'); size_t length = newline != NULL ? (size_t)(newline - line) : strlen(line); if (length > 0U && line[length - 1U] == '\r') length -= 1U; for (index = 0U; index < sizeof(values) / sizeof(values[0]); ++index) { size_t marker_length = strlen(values[index].marker); if (length == marker_length && memcmp(line, values[index].marker, marker_length) == 0) { result = values[index].status; } } if (newline == NULL) break; line = newline + 1; } return result; } TrainlogSyncDirectionPlan trainlog_sync_direction_plan( TrainlogSyncDirection direction ) { TrainlogSyncDirectionPlan plan = {false, false}; if (direction == TRAINLOG_SYNC_ANDROID_TO_PC || direction == TRAINLOG_SYNC_BIDIRECTIONAL) { plan.receive_android = true; } if (direction == TRAINLOG_SYNC_PC_TO_ANDROID || direction == TRAINLOG_SYNC_BIDIRECTIONAL) { plan.publish_android = true; } return plan; } static bool ensure_directory( const char *path ) { if ( path == NULL || path[0] == '\0' ) { return false; } if ( mkdir( path, 0700 ) == 0 ) { return true; } return errno == EEXIST; } static bool sync_data_root( char *output, size_t output_size ) { const char *data_home = getenv( "XDG_DATA_HOME" ); const char *home = getenv( "HOME" ); int written; if ( output == NULL || output_size == 0U ) { return false; } if ( data_home != NULL && data_home[0] != '\0' ) { written = snprintf( output, output_size, "%s/trainlog", data_home ); } else if ( home != NULL && home[0] != '\0' ) { char local_dir[ PATH_MAX + 1U ]; char share_dir[ PATH_MAX + 1U ]; written = snprintf( local_dir, sizeof(local_dir), "%s/.local", home ); if ( written < 0 || (size_t)written >= sizeof(local_dir) || !ensure_directory( local_dir ) ) { return false; } written = snprintf( share_dir, sizeof(share_dir), "%s/share", local_dir ); if ( written < 0 || (size_t)written >= sizeof(share_dir) || !ensure_directory( share_dir ) ) { return false; } written = snprintf( output, output_size, "%s/trainlog", share_dir ); } else { return false; } if ( written < 0 || (size_t)written >= output_size ) { return false; } return ensure_directory( output ); } static bool sync_runs_directory( char *output, size_t output_size ) { char root[ PATH_MAX + 1U ]; int written; if ( !sync_data_root( root, sizeof(root) ) ) { return false; } written = snprintf( output, output_size, "%s/sync_runs", root ); if ( written < 0 || (size_t)written >= output_size ) { return false; } return ensure_directory( output ); } static bool sync_data_file( const char *name, char *output, size_t output_size ) { char root[ PATH_MAX + 1U ]; int written; if ( name == NULL || !sync_data_root( root, sizeof(root) ) ) { return false; } written = snprintf( output, output_size, "%s/%s", root, name ); return written >= 0 && (size_t)written < output_size; } static int sync_lock_open( bool blocking ) { char path[ PATH_MAX + 1U ]; int descriptor; int operation = LOCK_EX; if ( !sync_data_file( "sync.lock", path, sizeof(path) ) ) { return -1; } descriptor = open( path, O_RDWR | O_CREAT | O_CLOEXEC, 0600 ); if (descriptor < 0) { return -1; } if (!blocking) { operation |= LOCK_NB; } if ( flock( descriptor, operation ) != 0 ) { (void)close( descriptor ); return -1; } return descriptor; } static void sync_lock_close( int descriptor ) { if (descriptor < 0) { return; } (void)flock( descriptor, LOCK_UN ); (void)close( descriptor ); } static bool sync_silence_begin( SyncSilence *silence ) { if (silence == NULL) { return false; } silence->saved_stdout = -1; silence->saved_stderr = -1; silence->null_fd = -1; (void)fflush(stdout); (void)fflush(stderr); silence->saved_stdout = dup( STDOUT_FILENO ); silence->saved_stderr = dup( STDERR_FILENO ); silence->null_fd = open( "/dev/null", O_WRONLY | O_CLOEXEC ); if ( silence->saved_stdout < 0 || silence->saved_stderr < 0 || silence->null_fd < 0 ) { return false; } if ( dup2( silence->null_fd, STDOUT_FILENO ) < 0 || dup2( silence->null_fd, STDERR_FILENO ) < 0 ) { return false; } return true; } static void sync_silence_end( SyncSilence *silence ) { if (silence == NULL) { return; } (void)fflush(stdout); (void)fflush(stderr); if ( silence->saved_stdout >= 0 ) { (void)dup2( silence->saved_stdout, STDOUT_FILENO ); (void)close( silence->saved_stdout ); } if ( silence->saved_stderr >= 0 ) { (void)dup2( silence->saved_stderr, STDERR_FILENO ); (void)close( silence->saved_stderr ); } if ( silence->null_fd >= 0 ) { (void)close( silence->null_fd ); } silence->saved_stdout = -1; silence->saved_stderr = -1; silence->null_fd = -1; } static TrainlogStatus sync_probe_unlocked( TrainlogSyncDeviceInfo *output ) { TrainlogUsbDevice devices[SYNC_DEVICE_CAPACITY]; TrainlogMtpStorage storages[SYNC_STORAGE_CAPACITY]; size_t device_count = 0U; size_t storage_count = 0U; TrainlogStatus status; if (output == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } (void)memset( output, 0, sizeof(*output) ); status = trainlog_usb_list_mtp_devices( devices, SYNC_DEVICE_CAPACITY, &device_count ); if ( status != TRAINLOG_STATUS_OK || device_count == 0U ) { return TRAINLOG_STATUS_NOT_FOUND; } output->connected = true; output->device = devices[0]; status = trainlog_mtp_list_storages( output->device.bus_number, output->device.device_number, storages, SYNC_STORAGE_CAPACITY, &storage_count ); if ( status != TRAINLOG_STATUS_OK || storage_count == 0U ) { return TRAINLOG_STATUS_NOT_FOUND; } output->storage_ready = true; output->storage = storages[0]; return TRAINLOG_STATUS_OK; } TrainlogStatus trainlog_sync_probe( TrainlogSyncDeviceInfo *output ) { SyncSilence silence; int lock_fd; TrainlogStatus status; if (output == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } lock_fd = sync_lock_open( false ); if (lock_fd < 0) { return TRAINLOG_STATUS_CONFLICT; } if ( !sync_silence_begin( &silence ) ) { sync_silence_end( &silence ); sync_lock_close( lock_fd ); return TRAINLOG_STATUS_SYSTEM_ERROR; } status = sync_probe_unlocked( output ); sync_silence_end( &silence ); sync_lock_close( lock_fd ); return status; } static TrainlogStatus sync_find_child( const TrainlogSyncDeviceInfo *device, uint32_t parent_id, const char *name, bool folder, uint32_t *output_id, uint64_t *output_size ) { TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; size_t count = 0U; size_t index; TrainlogStatus status; if ( device == NULL || name == NULL || output_id == NULL || output_size == NULL || !device->connected || !device->storage_ready ) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_id = 0U; *output_size = 0U; status = trainlog_mtp_list_folder( device->device.bus_number, device->device.device_number, device->storage.storage_id, parent_id, entries, SYNC_ENTRY_CAPACITY, &count ); if ( status != TRAINLOG_STATUS_OK ) { return status; } for ( index = 0U; index < count; ++index ) { if ( entries[index].folder == folder && strcmp( entries[index].name, name ) == 0 ) { *output_id = entries[index].item_id; *output_size = entries[index] .size_bytes; return TRAINLOG_STATUS_OK; } } return TRAINLOG_STATUS_NOT_FOUND; } static bool sync_android_artifact_ordinal( const char *name, const char *canonical_name, uint32_t *output_ordinal ) { static const char suffix[] = ").json"; static const char extension[] = ".json"; size_t suffix_length; size_t extension_length; size_t canonical_length; size_t stem_length; size_t name_length; size_t index; uint32_t ordinal = 0U; if (name == NULL || canonical_name == NULL || output_ordinal == NULL) { return false; } if (strcmp(name, canonical_name) == 0) { *output_ordinal = 0U; return true; } suffix_length = sizeof(suffix) - 1U; extension_length = sizeof(extension) - 1U; canonical_length = strlen(canonical_name); if (canonical_length <= extension_length || strcmp(canonical_name + canonical_length - extension_length, extension) != 0) { return false; } stem_length = canonical_length - extension_length; name_length = strlen(name); if (name_length < stem_length + 3U + suffix_length || strncmp(name, canonical_name, stem_length) != 0 || name[stem_length] != ' ' || name[stem_length + 1U] != '(' || strcmp(name + name_length - suffix_length, suffix) != 0) { return false; } for (index = stem_length + 2U; index < name_length - suffix_length; ++index) { unsigned int digit; if (name[index] < '0' || name[index] > '9') { return false; } digit = (unsigned int)(name[index] - '0'); if (ordinal > (UINT32_MAX - digit) / 10U) { return false; } ordinal = ordinal * 10U + digit; } if (ordinal == 0U) { return false; } *output_ordinal = ordinal; return true; } bool trainlog_sync_select_android_artifact( const TrainlogMtpEntry *entries, size_t count, const char *canonical_name, size_t *output_index ) { bool found = false; uint64_t best_modified = 0U; uint32_t best_ordinal = 0U; uint32_t best_item_id = 0U; size_t best_index = 0U; size_t index; if (entries == NULL || count == 0U || canonical_name == NULL || canonical_name[0] == '\0' || output_index == NULL) { return false; } for (index = 0U; index < count; ++index) { uint32_t ordinal; const TrainlogMtpEntry *entry = &entries[index]; if (entry->folder || !sync_android_artifact_ordinal(entry->name, canonical_name, &ordinal)) { continue; } if (!found || entry->modification_unix_seconds > best_modified || (entry->modification_unix_seconds == best_modified && ordinal > best_ordinal) || (entry->modification_unix_seconds == best_modified && ordinal == best_ordinal && entry->item_id < best_item_id)) { found = true; best_modified = entry->modification_unix_seconds; best_ordinal = ordinal; best_item_id = entry->item_id; best_index = index; } } if (!found) { return false; } *output_index = best_index; return true; } bool trainlog_sync_select_mobile_export( const TrainlogMtpEntry *entries, size_t count, size_t *output_index ) { return trainlog_sync_select_android_artifact(entries, count, MOBILE_EXPORT_NAME, output_index); } static TrainlogStatus sync_find_exchange_folder( const TrainlogSyncDeviceInfo *device, uint32_t *output_folder_id ) { uint32_t documents_id = 0U; uint64_t ignored_size = 0U; TrainlogStatus status; if ( output_folder_id == NULL ) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } /* CONTRACT: Documents/Trainlog is the only writable exchange endpoint. * The historical Download/Trainlog tree may remain on the device, but no * publication path may resolve or mutate it. */ status = sync_find_child( device, UINT32_MAX, "Documents", true, &documents_id, &ignored_size ); if ( status != TRAINLOG_STATUS_OK ) { return status; } return sync_find_child( device, documents_id, "Trainlog", true, output_folder_id, &ignored_size ); } static TrainlogStatus sync_receive_named( const TrainlogSyncDeviceInfo *device, uint32_t folder_id, const char *name, const char *local_path, uint64_t *output_size ) { uint32_t item_id = 0U; uint64_t size_bytes = 0U; TrainlogStatus status; if ( name == NULL || local_path == NULL ) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } status = sync_find_child( device, folder_id, name, false, &item_id, &size_bytes ); if ( status != TRAINLOG_STATUS_OK ) { return status; } status = trainlog_mtp_receive_file( device->device.bus_number, device->device.device_number, item_id, local_path ); if ( status == TRAINLOG_STATUS_OK && output_size != NULL ) { *output_size = size_bytes; } return status; } static TrainlogStatus sync_receive_current_android_artifact( const TrainlogSyncDeviceInfo *device, uint32_t folder_id, const char *canonical_name, const char *local_path, uint64_t *output_size ) { TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; size_t count = 0U; size_t selected_index; TrainlogStatus status; if (device == NULL || canonical_name == NULL || local_path == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } status = trainlog_mtp_list_folder( device->device.bus_number, device->device.device_number, device->storage.storage_id, folder_id, entries, SYNC_ENTRY_CAPACITY, &count ); if (status != TRAINLOG_STATUS_OK) { return status; } if (!trainlog_sync_select_android_artifact(entries, count, canonical_name, &selected_index)) { return TRAINLOG_STATUS_NOT_FOUND; } status = trainlog_mtp_receive_file( device->device.bus_number, device->device.device_number, entries[selected_index].item_id, local_path ); if (status == TRAINLOG_STATUS_OK && output_size != NULL) { *output_size = entries[selected_index].size_bytes; } return status; } static TrainlogStatus sync_publish_named( const TrainlogSyncDeviceInfo *device, uint32_t folder_id, const char *local_path, const char *remote_name ) { uint32_t existing_id = 0U; uint32_t uploaded_id = 0U; uint64_t ignored_size = 0U; TrainlogStatus status; status = sync_find_child( device, folder_id, remote_name, false, &existing_id, &ignored_size ); if ( status == TRAINLOG_STATUS_OK ) { status = trainlog_mtp_delete_object( device->device.bus_number, device->device.device_number, existing_id ); if ( status != TRAINLOG_STATUS_OK ) { return status; } } else if ( status != TRAINLOG_STATUS_NOT_FOUND ) { return status; } return trainlog_mtp_send_text_file( device->device.bus_number, device->device.device_number, device->storage.storage_id, folder_id, local_path, remote_name, &uploaded_id ); } static bool sync_resolve_repo_tool( const char *tool_name, char *output, size_t output_size ) { const char *configured_directory; char executable[ PATH_MAX + 1U ]; ssize_t length; char *slash; int level; int written; if ( tool_name == NULL || output == NULL || output_size == 0U ) { return false; } /* WHY: installed TUI binaries are not necessarily nested under the source * tree, whereas the Python sync helpers remain repository-owned assets. * CONTRACT: an explicit process-local override is accepted for packaged * deployments and tests; otherwise Meson's absolute source tools path is * authoritative. INVARIANT: resolution never invokes a shell and accepts * only the caller-supplied fixed helper filename. */ configured_directory = getenv("TRAINLOG_TOOLS_DIR"); if (configured_directory != NULL && configured_directory[0] != '\0') { written = snprintf(output, output_size, "%s/%s", configured_directory, tool_name); return written >= 0 && (size_t)written < output_size && access(output, R_OK) == 0; } configured_directory = TRAINLOG_TOOLS_DIR; written = snprintf(output, output_size, "%s/%s", configured_directory, tool_name); if (written >= 0 && (size_t)written < output_size && access(output, R_OK) == 0) { return true; } /* Compatibility for binaries from older or relocated build trees. */ length = readlink( "/proc/self/exe", executable, PATH_MAX ); if ( length <= 0 || (size_t)length >= sizeof(executable) ) { return false; } executable[ (size_t)length ] = '\0'; for ( level = 0; level < 3; ++level ) { slash = strrchr( executable, '/' ); if ( slash == NULL || slash == executable ) { return false; } *slash = '\0'; } written = snprintf( output, output_size, "%s/tools/%s", executable, tool_name ); return written >= 0 && (size_t)written < output_size && access( output, R_OK ) == 0; } static bool sync_read_text( const char *path, char *output, size_t output_size ) { FILE *file; size_t used; if ( path == NULL || output == NULL || output_size < 2U ) { return false; } file = fopen( path, "rb" ); if (file == NULL) { return false; } used = fread( output, 1U, output_size - 1U, file ); if ( ferror(file) != 0 ) { (void)fclose(file); return false; } output[used] = '\0'; return fclose(file) == 0; } static TrainlogStatus sync_run_python_tool( const char *tool_name, const char *argument, const char *database_path, const char *mobile_export_path, const char *result_path, char *output, size_t output_size ) { char tool[ PATH_MAX + 1U ]; pid_t child; int child_status; int result_fd; if ( tool_name == NULL || argument == NULL || result_path == NULL || output == NULL || output_size < 2U || (mobile_export_path != NULL && database_path == NULL) ) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } output[0] = '\0'; result_fd = open( result_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600 ); if (result_fd < 0) { (void)snprintf(output, output_size, "résultat outil Python inaccessible (errno=%d)", errno); return TRAINLOG_STATUS_SYSTEM_ERROR; } /* INVARIANT: a prior run can never satisfy the current run's PASS check, * including when helper resolution fails before a child is started. */ if (!sync_resolve_repo_tool(tool_name, tool, sizeof(tool))) { (void)close(result_fd); (void)snprintf(output, output_size, "outil Python introuvable: %s", tool_name); return TRAINLOG_STATUS_NOT_FOUND; } child = fork(); if (child < (pid_t)0) { (void)close( result_fd ); (void)snprintf(output, output_size, "lancement outil Python échoué (errno=%d)", errno); return TRAINLOG_STATUS_SYSTEM_ERROR; } if (child == (pid_t)0) { if ( dup2( result_fd, STDOUT_FILENO ) < 0 || dup2( result_fd, STDERR_FILENO ) < 0 ) { _exit(126); } (void)close( result_fd ); if (database_path != NULL && mobile_export_path != NULL) { /* CONTRACT: reconciliation-aware companion importers may consume * the exact retained V2/V3 snapshot as stable-entry identity * proof. argv stays bounded and shell-free. */ execlp( "python3", "python3", tool, argument, "--database", database_path, "--mobile-export", mobile_export_path, (char *)NULL ); } else if (database_path != NULL) { /* CONTRACT: helpers which mutate the desktop store receive its * explicit XDG-resolved path. They must never infer a different * user's database from Python's process environment. */ execlp( "python3", "python3", tool, argument, "--database", database_path, (char *)NULL ); } else { execlp( "python3", "python3", tool, argument, (char *)NULL ); } (void)dprintf(STDERR_FILENO, "python3: exécution impossible (errno=%d)\n", errno); _exit(127); } (void)close( result_fd ); if ( waitpid( child, &child_status, 0 ) < (pid_t)0 ) { (void)snprintf(output, output_size, "attente outil Python échouée (errno=%d)", errno); return TRAINLOG_STATUS_SYSTEM_ERROR; } if (!sync_read_text(result_path, output, output_size)) { (void)snprintf(output, output_size, "lecture diagnostic outil Python échouée"); return TRAINLOG_STATUS_SYSTEM_ERROR; } if ( !WIFEXITED( child_status ) || WEXITSTATUS( child_status ) != 0 ) { if (output[0] == '\0') { if (WIFSIGNALED(child_status)) { (void)snprintf(output, output_size, "outil Python interrompu par signal %d", WTERMSIG(child_status)); } else { (void)snprintf(output, output_size, "outil Python terminé avec code %d", WIFEXITED(child_status) ? WEXITSTATUS(child_status) : -1); } } return TRAINLOG_STATUS_DATABASE_ERROR; } return TRAINLOG_STATUS_OK; } static size_t sync_report_value( const char *text, const char *name ) { const char *position; char *end = NULL; unsigned long long value; if ( text == NULL || name == NULL ) { return 0U; } position = strstr( text, name ); if (position == NULL) { return 0U; } position += strlen(name); if (*position != '=') { return 0U; } ++position; value = strtoull( position, &end, 10 ); if ( end == position || value > (unsigned long long) SIZE_MAX ) { return 0U; } return (size_t)value; } static void sync_last_nonempty_line( const char *text, char *output, size_t output_size ) { const char *cursor; const char *line_start; size_t best_length = 0U; const char *best = NULL; if ( output == NULL || output_size == 0U ) { return; } output[0] = '\0'; if (text == NULL) { return; } cursor = text; line_start = text; for (;;) { if ( *cursor == '\n' || *cursor == '\0' ) { size_t length = (size_t)( cursor - line_start ); while ( length > 0U && ( line_start[length - 1U] == '\r' || line_start[length - 1U] == ' ' || line_start[length - 1U] == '\t' ) ) { --length; } if (length > 0U) { best = line_start; best_length = length; } if (*cursor == '\0') { break; } line_start = cursor + 1; } ++cursor; } if (best != NULL) { size_t copy_length = best_length < output_size - 1U ? best_length : output_size - 1U; (void)memcpy( output, best, copy_length ); output[copy_length] = '\0'; } } static bool sync_json_string( const char *text, const char *key, char *output, size_t output_size ) { char pattern[128]; const char *position; const char *quote; const char *end; int written; size_t length; if ( text == NULL || key == NULL || output == NULL || output_size < 2U ) { return false; } written = snprintf( pattern, sizeof(pattern), "\"%s\"", key ); if ( written < 0 || (size_t)written >= sizeof(pattern) ) { return false; } position = strstr( text, pattern ); if (position == NULL) { return false; } position += strlen(pattern); position = strchr( position, ':' ); if (position == NULL) { return false; } ++position; while ( *position == ' ' || *position == '\t' || *position == '\r' || *position == '\n' ) { ++position; } if (*position != '"') { return false; } quote = position + 1; end = strchr( quote, '"' ); if (end == NULL) { return false; } length = (size_t)( end - quote ); if ( length == 0U || length >= output_size ) { return false; } (void)memcpy( output, quote, length ); output[length] = '\0'; return true; } static bool sync_parse_request( const char *text, char *output_request_id, size_t output_size ) { char format[64]; if ( text == NULL || output_request_id == NULL || output_size < 2U || !sync_json_string( text, "format", format, sizeof(format) ) || strcmp( format, "trainlog-sync-request" ) != 0 || strstr( text, "\"version\":1" ) == NULL ) { return false; } return sync_json_string( text, "request_id", output_request_id, output_size ); } static bool sync_last_request_matches( const char *request_id ) { char path[ PATH_MAX + 1U ]; char saved[ TRAINLOG_ID_MAX + 2U ]; size_t length; if ( request_id == NULL || request_id[0] == '\0' || !sync_data_file( "sync_last_request.txt", path, sizeof(path) ) || !sync_read_text( path, saved, sizeof(saved) ) ) { return false; } length = strlen(saved); while ( length > 0U && ( saved[length - 1U] == '\n' || saved[length - 1U] == '\r' ) ) { --length; } saved[length] = '\0'; return strcmp( saved, request_id ) == 0; } static bool sync_save_last_request( const char *request_id ) { char path[ PATH_MAX + 1U ]; FILE *file; if ( request_id == NULL || request_id[0] == '\0' || !sync_data_file( "sync_last_request.txt", path, sizeof(path) ) ) { return false; } file = fopen( path, "wb" ); if (file == NULL) { return false; } if ( fprintf( file, "%s\n", request_id ) < 0 ) { (void)fclose(file); return false; } return fclose(file) == 0; } static const char *sync_trigger_text( TrainlogSyncTrigger trigger ) { switch (trigger) { case TRAINLOG_SYNC_TRIGGER_ANDROID: return "android"; case TRAINLOG_SYNC_TRIGGER_DAEMON: return "daemon"; case TRAINLOG_SYNC_TRIGGER_TUI: default: return "tui"; } } static const char *sync_direction_text( TrainlogSyncDirection direction ) { switch (direction) { case TRAINLOG_SYNC_ANDROID_TO_PC: return "android_to_pc"; case TRAINLOG_SYNC_PC_TO_ANDROID: return "pc_to_android"; case TRAINLOG_SYNC_BIDIRECTIONAL: return "bidirectional"; default: return NULL; } } static bool sync_local_timestamp( char output[17] ) { time_t now = time(NULL); struct tm local_time; if ( now == (time_t)-1 || localtime_r( &now, &local_time ) == NULL ) { return false; } return strftime( output, 17U, "%d/%m/%Y %H:%M", &local_time ) != 0U; } static bool sync_json_write_escaped( FILE *file, const char *text ) { const unsigned char *cursor; if ( file == NULL || text == NULL ) { return false; } if ( fputc( '"', file ) == EOF ) { return false; } cursor = (const unsigned char *)text; while (*cursor != 0U) { switch (*cursor) { case '"': if ( fputs( "\\\"", file ) == EOF ) { return false; } break; case '\\': if ( fputs( "\\\\", file ) == EOF ) { return false; } break; case '\n': if ( fputs( "\\n", file ) == EOF ) { return false; } break; case '\r': if ( fputs( "\\r", file ) == EOF ) { return false; } break; case '\t': if ( fputs( "\\t", file ) == EOF ) { return false; } break; default: if ( fputc( (int)*cursor, file ) == EOF ) { return false; } break; } ++cursor; } return fputc( '"', file ) != EOF; } static bool sync_history_write_summary( FILE *file, const char *summary ) { size_t index; if (file == NULL || summary == NULL) { return false; } for ( index = 0U; index < TRAINLOG_SYNC_SUMMARY_MAX && summary[index] != '\0'; ++index ) { const char value = summary[index] == '\t' || summary[index] == '\r' || summary[index] == '\n' ? ' ' : summary[index]; if (fputc((unsigned char)value, file) == EOF) { return false; } } return true; } static bool sync_write_receipt( const TrainlogSyncReport *report ) { FILE *file; if ( report == NULL || report->request_id[0] == '\0' ) { return false; } file = fopen( SYNC_RECEIPT_LOCAL, "wb" ); if (file == NULL) { return false; } (void)fputs( "{\n \"format\":\"trainlog-sync-receipt\",\n" " \"version\":1,\n" " \"request_id\":", file ); if ( !sync_json_write_escaped( file, report->request_id ) ) { (void)fclose(file); return false; } (void)fputs( ",\n \"sync_id\":", file ); if ( !sync_json_write_escaped( file, report->sync_id ) ) { (void)fclose(file); return false; } (void)fputs( ",\n \"status\":", file ); if ( !sync_json_write_escaped( file, report->success ? "success" : "failure" ) ) { (void)fclose(file); return false; } (void)fputs( ",\n \"summary\":", file ); if ( !sync_json_write_escaped( file, report->summary ) ) { (void)fclose(file); return false; } (void)fprintf( file, ",\n \"android_to_pc\":{" "\"exercises_imported\":%zu," "\"exercises_reconciled\":%zu," "\"exercises_skipped\":%zu," "\"sessions_imported\":%zu," "\"sessions_skipped\":%zu," "\"body_imported\":%zu," "\"body_skipped\":%zu" "},\n" " \"pc_to_android\":{" "\"catalog_published\":%zu" "}\n}\n", report->exercises_imported, report->exercises_reconciled, report->exercises_skipped, report->sessions_imported, report->sessions_skipped, report->body_imported, report->body_skipped, report->catalog_published ); return fclose(file) == 0; } bool trainlog_sync_record_local_run( TrainlogSyncTrigger trigger, const TrainlogSyncReport *report ) { char directory[ PATH_MAX + 1U ]; char json_path[ PATH_MAX + 1U ]; char detail_path[ PATH_MAX + 1U ]; char history_path[ PATH_MAX + 1U ]; char local_timestamp[17]; FILE *json_file; FILE *detail_file; FILE *history_file; int written; const char *direction_code; const char *direction_text; const char *direction_label; TrainlogSyncHistoryEntry direction_entry; (void)memset( &direction_entry, 0, sizeof(direction_entry) ); if (report != NULL) { direction_entry.direction_known = true; direction_entry.direction = report->direction; } direction_code = report == NULL ? NULL : trainlog_sync_history_direction_code( report->direction ); direction_text = report == NULL ? NULL : sync_direction_text( report->direction ); direction_label = report == NULL ? NULL : trainlog_sync_history_direction_label( &direction_entry ); if ( report == NULL || direction_code == NULL || direction_text == NULL || direction_label == NULL || report->sync_id[0] == '\0' || !sync_runs_directory( directory, sizeof(directory) ) || !sync_data_file( "sync_history.log", history_path, sizeof(history_path) ) || !sync_local_timestamp( local_timestamp ) ) { return false; } written = snprintf( json_path, sizeof(json_path), "%s/%s.json", directory, report->sync_id ); if ( written < 0 || (size_t)written >= sizeof(json_path) ) { return false; } written = snprintf( detail_path, sizeof(detail_path), "%s/%s.txt", directory, report->sync_id ); if ( written < 0 || (size_t)written >= sizeof(detail_path) ) { return false; } json_file = fopen( json_path, "wb" ); if (json_file == NULL) { return false; } (void)fputs( "{\n" " \"format\":\"trainlog-sync-run\",\n" " \"version\":1,\n" " \"sync_id\":", json_file ); if ( !sync_json_write_escaped( json_file, report->sync_id ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"started_at\":", json_file ); if ( !sync_json_write_escaped( json_file, report->started_at ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"trigger\":", json_file ); if ( !sync_json_write_escaped( json_file, sync_trigger_text( trigger ) ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"direction\":", json_file ); if ( !sync_json_write_escaped( json_file, direction_text ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"status\":", json_file ); if ( !sync_json_write_escaped( json_file, report->success ? "success" : "failure" ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"request_id\":", json_file ); if ( !sync_json_write_escaped( json_file, report->request_id ) ) { (void)fclose(json_file); return false; } (void)fputs( ",\n \"summary\":", json_file ); if ( !sync_json_write_escaped( json_file, report->summary ) ) { (void)fclose(json_file); return false; } (void)fputs(",\n \"ai_inbound_status\":", json_file); if (!sync_json_write_escaped(json_file, sync_ai_inbound_status_text(report->ai_inbound_status))) { (void)fclose(json_file); return false; } (void)fprintf( json_file, ",\n \"android_to_pc\":{" "\"exercises_imported\":%zu," "\"exercises_reconciled\":%zu," "\"exercises_skipped\":%zu," "\"sessions_imported\":%zu," "\"sessions_skipped\":%zu," "\"body_imported\":%zu," "\"body_skipped\":%zu" "},\n" " \"pc_to_android\":{" "\"catalog_published\":%zu" "}\n}\n", report->exercises_imported, report->exercises_reconciled, report->exercises_skipped, report->sessions_imported, report->sessions_skipped, report->body_imported, report->body_skipped, report->catalog_published ); if ( fclose( json_file ) != 0 ) { return false; } detail_file = fopen( detail_path, "wb" ); if (detail_file == NULL) { return false; } (void)fprintf( detail_file, "SYNC %s\n\n" "Déclencheur : %s\n" "Direction : %s\n" "Début : %s\n" "État : %s\n", report->sync_id, sync_trigger_text( trigger ), direction_label, local_timestamp, report->success ? "succès" : "échec" ); if ( report->request_id[0] != '\0' ) { (void)fprintf( detail_file, "Requête : %s\n", report->request_id ); } if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) { (void)fprintf(detail_file, "AI entrant : %s\n", sync_ai_inbound_status_text(report->ai_inbound_status)); } (void)fprintf( detail_file, "\nANDROID → PC\n" " Exercices importés +%zu\n" " Exercices réconciliés +%zu\n" " Exercices déjà présents %zu\n" " Séances importées +%zu\n" " Séances déjà présentes %zu\n" " Mensurations importées +%zu\n" " Mensurations déjà présentes %zu\n" "\nPC → ANDROID\n" " Catalogue publié %zu exercice(s)\n" "\nRésumé\n" " %s\n", report->exercises_imported, report->exercises_reconciled, report->exercises_skipped, report->sessions_imported, report->sessions_skipped, report->body_imported, report->body_skipped, report->catalog_published, report->summary ); if ( report->error[0] != '\0' ) { (void)fprintf( detail_file, "\nErreur\n %s\n", report->error ); } if ( fclose( detail_file ) != 0 ) { return false; } history_file = fopen( history_path, "ab" ); if (history_file == NULL) { return false; } /* CONTRACT: direction is a dedicated field because failure summaries may * be neutral or mention a different phase than the selected operation. */ written = fprintf( history_file, "%s\t%s\t%d\t%s\t", report->sync_id, local_timestamp, report->success ? 1 : 0, direction_code ); if ( written < 0 || !sync_history_write_summary( history_file, report->summary ) || fputc('\n', history_file) == EOF ) { (void)fclose(history_file); return false; } return fclose( history_file ) == 0; } void trainlog_sync_build_summary( TrainlogSyncReport *report ) { if (report == NULL) { return; } if (report->success) { if (report->direction == TRAINLOG_SYNC_ANDROID_TO_PC) { (void)snprintf(report->summary, sizeof(report->summary), "Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s)", report->sessions_imported, report->exercises_imported, report->body_imported, report->equipment_definitions_imported); } else if (report->direction == TRAINLOG_SYNC_PC_TO_ANDROID) { (void)snprintf(report->summary, sizeof(report->summary), "PC→Android catalogue %zu exercice(s), définitions/séances/mesures publiées", report->catalog_published); } else { (void)snprintf(report->summary, sizeof(report->summary), "PC↔Android import +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s) · catalogue %zu publié(s)", report->sessions_imported, report->exercises_imported, report->body_imported, report->equipment_definitions_imported, report->catalog_published); } } else { sync_compose_diagnostic( report->summary, sizeof(report->summary), "", report->error[0] != '\0' ? report->error : "Synchronisation échouée." ); } if (report->ai_inbound_status != TRAINLOG_AI_INBOUND_NOT_RUN) { size_t used = strlen(report->summary); if (used < sizeof(report->summary)) { (void)snprintf(report->summary + used, sizeof(report->summary) - used, " · AI_INBOUND=%s", sync_ai_inbound_status_text(report->ai_inbound_status)); } } } static void sync_append_post_sync_summary( TrainlogSyncReport *report, const char *tool_output ) { char export_status[96] = "AI_EXPORT=FAIL"; char upload_status[96] = "GDRIVE_UPLOAD=SKIPPED"; const char *line; size_t used; if (report == NULL) { return; } line = tool_output != NULL ? strstr(tool_output, "AI_EXPORT=") : NULL; if (line != NULL) { size_t length = strcspn(line, "\r\n"); (void)snprintf(export_status, sizeof(export_status), "%.*s", (int)(length < sizeof(export_status) - 1U ? length : sizeof(export_status) - 1U), line); } line = tool_output != NULL ? strstr(tool_output, "GDRIVE_UPLOAD=") : NULL; if (line != NULL) { size_t length = strcspn(line, "\r\n"); (void)snprintf(upload_status, sizeof(upload_status), "%.*s", (int)(length < sizeof(upload_status) - 1U ? length : sizeof(upload_status) - 1U), line); } used = strlen(report->summary); if (used < sizeof(report->summary)) { /* WHY: post-sync publication is operational follow-up, not part of * the already committed Trainlog synchronization. CONTRACT: expose * each outcome while preserving report.success and the sync return * status. INVARIANT: the fixed report buffer remains terminated. */ (void)snprintf( report->summary + used, sizeof(report->summary) - used, " · SYNC=PASS %s %s", export_status, upload_status ); } } static TrainlogStatus sync_prepare_run_identity( TrainlogSyncReport *report ) { if ( report == NULL || trainlog_id_generate( "sy", report->sync_id, sizeof(report->sync_id) ) != TRAINLOG_STATUS_OK || trainlog_time_now_rfc3339( report->started_at, sizeof(report->started_at) ) != TRAINLOG_STATUS_OK ) { return TRAINLOG_STATUS_SYSTEM_ERROR; } return TRAINLOG_STATUS_OK; } static void sync_parse_import_report( const char *text, TrainlogSyncReport *report ) { if ( text == NULL || report == NULL ) { return; } report->exercises_imported = sync_report_value( text, "exercises_imported" ); report->exercises_reconciled = sync_report_value( text, "exercises_reconciled" ); report->exercises_skipped = sync_report_value( text, "exercises_skipped" ); report->sessions_imported = sync_report_value( text, "sessions_imported" ); report->sessions_skipped = sync_report_value( text, "sessions_skipped" ); report->body_imported = sync_report_value( text, "body_imported" ); report->body_skipped = sync_report_value( text, "body_skipped" ); } /* TRAINLOG_SYNCD_NO_UNKNOWN_ERRORS */ TrainlogStatus trainlog_sync_run( TrainlogSyncTrigger trigger, bool require_request, TrainlogSyncDirection direction, TrainlogSyncReport *output ) { TrainlogSyncDeviceInfo device; SyncSilence silence; TrainlogStatus status; TrainlogStatus final_status = TRAINLOG_STATUS_OK; char tool_output[ SYNC_TOOL_OUTPUT_MAX + 1U ]; char request_text[ SYNC_REQUEST_TEXT_MAX + 1U ]; char database_path[ PATH_MAX + 1U ]; uint32_t folder_id = 0U; uint64_t ignored_size = 0U; int lock_fd = -1; bool silence_active = false; bool run_started = false; bool receipt_published = false; bool mobile_export_has_companions = false; bool profile_state_received = false; bool retained_mobile_export_is_v2 = false; if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC || direction > TRAINLOG_SYNC_BIDIRECTIONAL || (require_request && direction != TRAINLOG_SYNC_BIDIRECTIONAL)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } (void)memset( output, 0, sizeof(*output) ); output->direction = direction; lock_fd = sync_lock_open( !require_request ); if (lock_fd < 0) { (void)snprintf( output->error, sizeof(output->error), "%s", "Une autre synchronisation est déjà en cours." ); return TRAINLOG_STATUS_CONFLICT; } if ( !sync_silence_begin( &silence ) ) { sync_silence_end( &silence ); sync_lock_close( lock_fd ); (void)snprintf( output->error, sizeof(output->error), "%s", "Impossible d'isoler la sortie MTP." ); return TRAINLOG_STATUS_SYSTEM_ERROR; } silence_active = true; status = sync_probe_unlocked( &device ); if ( status != TRAINLOG_STATUS_OK ) { (void)snprintf( output->error, sizeof(output->error), "Détection MTP échouée (status=%d).", (int)status ); final_status = status; goto done; } status = sync_find_exchange_folder( &device, &folder_id ); if ( status != TRAINLOG_STATUS_OK ) { (void)snprintf( output->error, sizeof(output->error), "Résolution de Documents/Trainlog échouée (status=%d).", (int)status ); final_status = status; goto done; } if (require_request) { /* WHY: MediaStore cannot always reopen an older MTP-created object by * canonical name, so Android may publish the next request as the exact * scoped-storage collision form " (N).json". CONTRACT: select the * newest canonical-or-suffixed request deterministically, exactly as * for the other Android-originated artifacts; request_id replay * protection below remains the trigger authority. */ status = sync_receive_current_android_artifact( &device, folder_id, SYNC_REQUEST_NAME, SYNC_REQUEST_LOCAL, &ignored_size ); if ( status == TRAINLOG_STATUS_NOT_FOUND ) { final_status = TRAINLOG_STATUS_NOT_FOUND; goto done; } if ( status != TRAINLOG_STATUS_OK ) { (void)snprintf( output->error, sizeof(output->error), "Lecture MTP de la requête Android échouée (status=%d).", (int)status ); final_status = status; goto done; } if ( !sync_read_text( SYNC_REQUEST_LOCAL, request_text, sizeof(request_text) ) ) { (void)snprintf( output->error, sizeof(output->error), "%s", "Lecture locale de la requête Android échouée." ); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto done; } if ( !sync_parse_request( request_text, output->request_id, sizeof(output->request_id) ) ) { (void)snprintf( output->error, sizeof(output->error), "%s", "JSON de requête Android invalide." ); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto done; } output->request_present = true; if ( sync_last_request_matches( output->request_id ) ) { final_status = TRAINLOG_STATUS_NOT_FOUND; goto done; } } status = sync_prepare_run_identity( output ); if ( status != TRAINLOG_STATUS_OK ) { (void)snprintf( output->error, sizeof(output->error), "Création de l'identité de synchronisation échouée (status=%d).", (int)status ); final_status = status; goto done; } run_started = true; /* INVARIANT: every local import/export in one run addresses the same * XDG-resolved database as the TUI, never a Python-derived fallback. */ if (!sync_data_file("trainlog.db", database_path, sizeof(database_path))) { (void)snprintf(output->error, sizeof(output->error), "Synchronisation : chemin de base introuvable."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { goto ai_draft_midpoint; } /* WHY: aliases must exist before the frozen session/catalog snapshot is * interpreted, so a retired source ID resolves without changing V3. */ status = sync_receive_current_android_artifact(&device, folder_id, EXERCISE_ALIASES_NAME, EXERCISE_ALIASES_LOCAL, &ignored_size); if (status == TRAINLOG_STATUS_OK) { status = sync_run_python_tool("import_exercise_aliases.py", EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_ALIAS_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : alias exercices : ", useful[0] != '\0' ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture alias exercices échouée."); final_status = status; goto finalize; } /* Definitions must reconcile before either V3/V2 reference artifact. A * missing file is accepted only for historic snapshots with no custom ID. */ status = sync_receive_current_android_artifact( &device, folder_id, MOBILE_EQUIPMENT_DEFINITIONS_NAME, MOBILE_EQUIPMENT_DEFINITIONS_LOCAL, &ignored_size ); if (status == TRAINLOG_STATUS_OK) { status = sync_run_python_tool("import_equipment_definitions.py", MOBILE_EQUIPMENT_DEFINITIONS_LOCAL, database_path, NULL, EQUIPMENT_DEFINITIONS_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic( output->error, sizeof(output->error), "Android→PC : définitions équipement : ", useful[0] != '\0' ? useful : "import échoué" ); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } output->equipment_definitions_imported = sync_report_value(tool_output, "definitions_imported"); output->equipment_definitions_skipped = sync_report_value(tool_output, "definitions_skipped"); } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture définitions équipement échouée."); final_status = status; goto finalize; } status = sync_receive_current_android_artifact( &device, folder_id, MOBILE_EXPORT_NAME, MOBILE_EXPORT_LOCAL, &ignored_size ); if (status == TRAINLOG_STATUS_NOT_FOUND) { /* CONTRACT: selected V3 content is authoritative. Only absence permits * V2 selection; an invalid V3 is imported and fails without fallback. */ status = sync_receive_current_android_artifact( &device, folder_id, MOBILE_EXPORT_V2_NAME, MOBILE_EXPORT_LOCAL, &ignored_size); retained_mobile_export_is_v2 = status == TRAINLOG_STATUS_OK; } mobile_export_has_companions = status == TRAINLOG_STATUS_OK; if (status == TRAINLOG_STATUS_NOT_FOUND) { status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME, MOBILE_EXPORT_LOCAL, &ignored_size); } if (status != TRAINLOG_STATUS_OK) { (void)snprintf( output->error, sizeof(output->error), "%s", "Android→PC : snapshot mobile introuvable ou illisible." ); final_status = status; goto finalize; } /* CONTRACT: causal CURRENT-profile state precedes occurrence import, so a * legitimate direct descendant (including the explicit legacy repair) * cannot be rejected by the unchanged V3 occurrence validator. */ status = mobile_export_has_companions ? sync_receive_current_android_artifact(&device, folder_id, EXERCISE_PROFILE_STATE_NAME, EXERCISE_PROFILE_STATE_LOCAL, &ignored_size) : TRAINLOG_STATUS_NOT_FOUND; if (status == TRAINLOG_STATUS_OK) { profile_state_received = true; status = sync_run_python_tool("import_exercise_profile_state.py", EXERCISE_PROFILE_STATE_LOCAL, database_path, MOBILE_EXPORT_LOCAL, EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : profils : ", useful[0] ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture profile-state échouée."); final_status = status; goto finalize; } status = sync_run_python_tool( "import_mobile_export.py", MOBILE_EXPORT_LOCAL, database_path, NULL, MOBILE_IMPORT_RESULT, tool_output, sizeof(tool_output) ); if ( status != TRAINLOG_STATUS_OK || strstr( tool_output, "MOBILE_IMPORT=PASS" ) == NULL ) { char useful[ TRAINLOG_SYNC_ERROR_MAX + 1U ]; sync_last_nonempty_line( tool_output, useful, sizeof(useful) ); /* WHY: V3/V2 peers may predate profile-state, so absence alone stays * compatible. It becomes actionable only when the unchanged mobile * importer proves that causal profile reconciliation was required. * INVARIANT: never disguise unrelated import failures as capability * failures. */ if (mobile_export_has_companions && !profile_state_received && strstr(useful, "profil incompatible entre identités") != NULL) { sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : PROFILE_STATE_COMPANION_MISSING : ", useful); } else { sync_compose_diagnostic( output->error, sizeof(output->error), "Android→PC : ", useful[0] != '\0' ? useful : "import mobile échoué" ); } final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } sync_parse_import_report( tool_output, output ); /* The pre-pass may defer identities introduced by this mobile catalogue. * This strict pass now requires every state record to resolve. */ if (profile_state_received) { status = sync_run_python_tool("import_exercise_profile_state.py", EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : vérification profils : ", useful[0] ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } /* CONTRACT: body zones are one directional-neutral companion. Historic * V2 publishers may omit it; when present beside V3/V2 it is applied after * exercise definitions above established every ID or a source-V2-proven * normalized-name alias. The helper consumes the retained mobile snapshot * solely as reconciliation proof; it never infers identity from a name. */ status = mobile_export_has_companions ? sync_receive_current_android_artifact(&device, folder_id, EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size) : TRAINLOG_STATUS_NOT_FOUND; if (status == TRAINLOG_STATUS_OK) { status = sync_run_python_tool("import_exercise_body_zones.py", EXERCISE_BODY_ZONES_LOCAL, database_path, retained_mobile_export_is_v2 ? MOBILE_EXPORT_LOCAL : NULL, EXERCISE_BODY_ZONES_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : zones corporelles : ", useful[0] != '\0' ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture zones corporelles échouée."); final_status = status; goto finalize; } /* V1 exports carry no equipment signal. A V3/V2 companion beside a historic * V1 snapshot belongs to another generation and must not be applied. Its * absence therefore preserves existing associations rather than clearing * them. Scoped storage may suffix every current Android publication, so the * path selects its newest candidate instead of an older canonical object. */ status = mobile_export_has_companions ? sync_receive_current_android_artifact( &device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME, EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size ) : TRAINLOG_STATUS_NOT_FOUND; if (status == TRAINLOG_STATUS_OK) { char useful[ TRAINLOG_SYNC_ERROR_MAX + 1U ]; status = sync_run_python_tool("import_equipment_associations.py", EQUIPMENT_ASSOCIATIONS_LOCAL, database_path, MOBILE_EXPORT_LOCAL, EQUIPMENT_ASSOCIATIONS_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) { sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic( output->error, sizeof(output->error), "Android→PC : import équipement : ", useful[0] != '\0' ? useful : "échec sans diagnostic du script" ); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture extension équipement échouée."); final_status = status; goto finalize; } /* CONTRACT: feedback references are resolved only after aliases, * catalogue/session snapshots, and occurrence equipment reconciliation. */ status = sync_receive_current_android_artifact(&device, folder_id, TRAINING_FEEDBACK_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size); if (status == TRAINLOG_STATUS_NOT_FOUND) { /* V2 is authoritative, but a deployed V1-only peer remains readable. * The importer turns its immutable raw_text into fr0_ and * cannot erase locally known revisions. */ status = sync_receive_current_android_artifact(&device, folder_id, TRAINING_FEEDBACK_V1_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size); } if (status == TRAINLOG_STATUS_OK) { status = sync_run_python_tool("import_training_feedback.py", TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "Android→PC : ressentis : ", useful[0] ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } } else if (status != TRAINLOG_STATUS_NOT_FOUND) { (void)snprintf(output->error, sizeof(output->error), "Android→PC : lecture ressentis échouée."); final_status = status; goto finalize; } ai_draft_midpoint: /* WHY: an Android-triggered bidirectional run must publish a newly fetched * AI draft immediately, not one synchronization later. CONTRACT: the * shell-free helper owns Drive fetch, strict transactional import, and * post-commit archive; transport/archive failures are explicitly nonfatal. * INVARIANT: this midpoint precedes every PC→Android artifact and receipt. */ status = sync_run_python_tool("sync_ai_session_draft_drive.py", "trainlog_ai_session_draft_v1.json", database_path, NULL, AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output)); output->ai_inbound_status = sync_parse_ai_inbound_status(tool_output); if (status != TRAINLOG_STATUS_OK) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "AI→PC : brouillon séance : ", useful[0] != '\0' ? useful : "import échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } if (output->ai_inbound_status == TRAINLOG_AI_INBOUND_NOT_RUN) { (void)snprintf(output->error, sizeof(output->error), "AI→PC : statut du brouillon séance absent."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { output->success = true; final_status = TRAINLOG_STATUS_OK; goto finalize; } status = sync_run_python_tool("export_ai_session_drafts.py", AI_SESSION_DRAFTS_LOCAL, database_path, NULL, AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "AI_SESSION_DRAFT_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export brouillons IA échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, AI_SESSION_DRAFTS_LOCAL, AI_SESSION_DRAFTS_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication brouillons IA échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("mark_ai_session_drafts_published.py", AI_SESSION_DRAFTS_LOCAL, database_path, NULL, AI_SESSION_DRAFT_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "AI_SESSION_DRAFT_PUBLISH_MARK=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : enregistrement publication brouillons IA échoué."); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } status = sync_run_python_tool("export_exercise_profile_state.py", EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_PROFILE_STATE_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export profile-state échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, EXERCISE_PROFILE_STATE_LOCAL, EXERCISE_PROFILE_STATE_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication profile-state échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("export_exercise_aliases.py", EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_ALIAS_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export alias exercices échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, EXERCISE_ALIASES_LOCAL, EXERCISE_ALIASES_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication alias exercices échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("export_training_feedback.py", TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export ressentis échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, TRAINING_FEEDBACK_LOCAL, TRAINING_FEEDBACK_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication ressentis échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("export_equipment_definitions.py", PC_EQUIPMENT_DEFINITIONS_LOCAL, database_path, NULL, EQUIPMENT_DEFINITIONS_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EQUIPMENT_DEFINITIONS_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export définitions équipement échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, PC_EQUIPMENT_DEFINITIONS_LOCAL, PC_EQUIPMENT_DEFINITIONS_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication définitions équipement échouée."); final_status = status; goto finalize; } status = sync_run_python_tool( "export_pc_catalog.py", PC_CATALOG_LOCAL, database_path, NULL, PC_CATALOG_RESULT, tool_output, sizeof(tool_output) ); if ( status != TRAINLOG_STATUS_OK || strstr( tool_output, "PC_CATALOG_EXPORT=PASS" ) == NULL ) { char useful[ TRAINLOG_SYNC_ERROR_MAX + 1U ]; sync_last_nonempty_line( tool_output, useful, sizeof(useful) ); sync_compose_diagnostic( output->error, sizeof(output->error), "PC→Android : ", useful[0] != '\0' ? useful : "export catalogue échoué" ); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } output->catalog_published = sync_report_value( tool_output, "exercises" ); status = sync_publish_named(&device, folder_id, PC_CATALOG_LOCAL, PC_CATALOG_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication MTP du catalogue échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("export_exercise_body_zones.py", EXERCISE_BODY_ZONES_LOCAL, database_path, NULL, EXERCISE_BODY_ZONES_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export zones corporelles échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, EXERCISE_BODY_ZONES_LOCAL, EXERCISE_BODY_ZONES_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication zones corporelles échouée."); final_status = status; goto finalize; } /* WHY: a new custom exercise has no common sync ancestor yet. Only after * the exact companion is durably visible to the peer may the publisher * acknowledge that snapshot as its baseline. Reusing the strict importer * records equal state and can never union secondary zones. */ status = sync_run_python_tool("import_exercise_body_zones.py", EXERCISE_BODY_ZONES_LOCAL, database_path, NULL, EXERCISE_BODY_ZONES_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_compose_diagnostic(output->error, sizeof(output->error), "PC→Android : baseline zones corporelles : ", useful[0] != '\0' ? useful : "enregistrement échoué"); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL, database_path, NULL, PC_CATALOG_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V3 échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V3 échouée."); final_status = status; goto finalize; } status = sync_run_python_tool("export_equipment_associations.py", EQUIPMENT_ASSOCIATIONS_LOCAL, database_path, NULL, EQUIPMENT_ASSOCIATIONS_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS") == NULL) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : export équipement échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_LOCAL, EQUIPMENT_ASSOCIATIONS_NAME); if (status != TRAINLOG_STATUS_OK) { (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication équipement échouée."); final_status = status; goto finalize; } output->success = true; final_status = TRAINLOG_STATUS_OK; finalize: trainlog_sync_build_summary( output ); if ( output->request_present ) { if ( sync_write_receipt( output ) ) { status = sync_publish_named( &device, folder_id, SYNC_RECEIPT_LOCAL, SYNC_RECEIPT_NAME ); if ( status == TRAINLOG_STATUS_OK ) { receipt_published = true; } } if (!receipt_published) { output->success = false; (void)snprintf( output->error, sizeof(output->error), "%s", "PC→Android : impossible de publier le reçu de synchronisation." ); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; trainlog_sync_build_summary( output ); } } if ( output->request_present && receipt_published ) { (void)sync_save_last_request( output->request_id ); } if (final_status == TRAINLOG_STATUS_OK && output->success) { TrainlogStatus post_status = sync_run_python_tool( "post_sync_ai_drive.py", "trainlog_ai_export_v1.json", database_path, NULL, AI_POST_SYNC_RESULT, tool_output, sizeof(tool_output) ); /* A launcher failure has no script output, but it must still remain a * visible post-sync failure rather than changing Trainlog sync state. */ if (post_status != TRAINLOG_STATUS_OK && tool_output[0] == '\0') { (void)snprintf(tool_output, sizeof(tool_output), "AI_EXPORT=FAIL launcher status=%d\n" "GDRIVE_UPLOAD=SKIPPED\n", (int)post_status); } sync_append_post_sync_summary(output, tool_output); } (void)trainlog_sync_record_local_run( trigger, output ); done: if (silence_active) { sync_silence_end( &silence ); } sync_lock_close( lock_fd ); if ( run_started && !output->success && output->error[0] == '\0' ) { (void)snprintf( output->error, sizeof(output->error), "%s", "Synchronisation échouée." ); } return final_status; }