Preserve the local graph-first investigation stack through macro V1. Include reviewed source, tests, contracts, documentation and synthetic assets. Retain the standalone financial V21 test foundation without runtime migration. Runtime remains V20; operational JobStore remains V3. Local experimental checkpoint, not a stable release.
1864 lines
92 KiB
C
1864 lines
92 KiB
C
#include "core/core_graph_projection_service.h"
|
||
#include "core/core_graph_snapshot_serializer.h"
|
||
#include "core/eml_analysis_persistence_service.h"
|
||
#include "core/evidence_importer.h"
|
||
#include "core/evidence_preview.h"
|
||
#include "core/file_hash.h"
|
||
#include "core/exiftool_persistence_service.h"
|
||
#include "core/local_capability_registry.h"
|
||
#include "core/local_job_store.h"
|
||
#include "core/local_correlation_service.h"
|
||
#include "core/local_planner_service.h"
|
||
#include "core/local_report_service.h"
|
||
#include "core/local_tool_runner.h"
|
||
#include "core/observation_review_service.h"
|
||
#include "dao/evidence_entity_dao.h"
|
||
#include "dao/evidence_dao.h"
|
||
#include "dao/extraction_dao.h"
|
||
#include "dao/investigation_dao.h"
|
||
#include "database/database.h"
|
||
#include "database/statement.h"
|
||
#include "models/evidence_record.h"
|
||
#include "models/investigation_record.h"
|
||
|
||
#include <fcntl.h>
|
||
#include <errno.h>
|
||
#include <glib/gstdio.h>
|
||
#include <json-glib/json-glib.h>
|
||
#include <stdio.h>
|
||
#include <signal.h>
|
||
#include <string.h>
|
||
#include <sys/file.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/wait.h>
|
||
#include <unistd.h>
|
||
|
||
#define EML_JOB "82000000-0000-4000-8000-000000000011"
|
||
#define EML_REQUEST "82000000-0000-4000-8000-000000000012"
|
||
#define EML_DERIVATIVE "82000000-0000-4000-8000-000000000013"
|
||
#define EXIF_JOB "82000000-0000-4000-8000-000000000021"
|
||
#define EXIF_REQUEST "82000000-0000-4000-8000-000000000022"
|
||
#define EXIF_DERIVATIVE "82000000-0000-4000-8000-000000000023"
|
||
|
||
static const char *const timestamp = "2026-09-26T21:00:00Z";
|
||
static const char *const eml_content =
|
||
"From: Alice SPECIMEN <alice@example.test>\r\n"
|
||
"To: Bob SPECIMEN <bob@example.test>\r\n"
|
||
"Received: from relay.example.test ([192.0.2.80]) by mx.example.test;\r\n"
|
||
"Message-ID: <j5@example.test>\r\nDate: Sat, 26 Sep 2026 21:00:00 +0200\r\n"
|
||
"Subject: Reprise J5\r\n\r\nContenu entièrement synthétique.\r\n";
|
||
static const char *const png_base64 =
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+"
|
||
"A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||
static const char *const j7_eml_a =
|
||
"From: Alice SPECIMEN <alice@example.test>\r\n"
|
||
"To: Pivot SPECIMEN <pivot@shared.test>\r\n"
|
||
"Message-ID: <j7-a@shared.test>\r\n"
|
||
"Received: from relay.shared.test ([192.0.2.44]) by mx.shared.test;\r\n"
|
||
"Subject: Homonyme SPECIMEN\r\n\r\nCorpus synthétique J7.\r\n";
|
||
static const char *const j7_eml_b =
|
||
"From: Alice SPECIMEN <other@shared.test>\r\n"
|
||
"To: alice@example.test, alice@example.test\r\n"
|
||
"Message-ID: <j7-b@shared.test>\r\n"
|
||
"Received: from edge.shared.test ([192.0.2.44]) by mx.other.test;\r\n"
|
||
"Subject: Répétition SPECIMEN\r\n\r\nCorpus synthétique J7.\r\n";
|
||
static const char *const j7_eml_c =
|
||
"From: Homonyme SPECIMEN <unrelated@shared.test>\r\n"
|
||
"To: distinct@example.invalid\r\n"
|
||
"Message-ID: <j7-c@unrelated.test>\r\n"
|
||
"Received: from shared.test ([198.51.100.9]) by mx.unrelated.test;\r\n"
|
||
"Subject: Domaine seulement SPECIMEN\r\n\r\nAucune identité commune déduite.\r\n";
|
||
|
||
typedef struct {
|
||
char *investigation_id;
|
||
char *eml_id;
|
||
char *image_id;
|
||
char *title;
|
||
gboolean synthetic;
|
||
} Manifest;
|
||
|
||
static gboolean manifest_read(const char *root, Manifest *manifest,
|
||
GError **error);
|
||
static void manifest_clear(Manifest *manifest);
|
||
static LocalJobStore *open_store(const char *root, Manifest *manifest,
|
||
GError **error);
|
||
static gboolean write_private_atomic(const char *path, const char *contents,
|
||
GError **error);
|
||
|
||
static void json_add_nullable(JsonBuilder *builder, const char *name,
|
||
const char *value) {
|
||
json_builder_set_member_name(builder, name);
|
||
if (value != NULL) json_builder_add_string_value(builder, value);
|
||
else json_builder_add_null_value(builder);
|
||
}
|
||
|
||
static gboolean print_json_builder(JsonBuilder *builder, GError **error) {
|
||
JsonNode *root = json_builder_get_root(builder);
|
||
JsonGenerator *generator = json_generator_new();
|
||
json_generator_set_root(generator, root);
|
||
char *data = json_generator_to_data(generator, NULL);
|
||
gboolean ok = data != NULL && printf("%s\n", data) >= 0;
|
||
if (!ok) g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||
"Impossible d'écrire la réponse JSON.");
|
||
g_free(data); g_object_unref(generator); json_node_free(root);
|
||
return ok;
|
||
}
|
||
|
||
static volatile sig_atomic_t worker_stop_requested = 0;
|
||
|
||
typedef struct { GMutex mutex; GCond condition; gboolean finished;
|
||
guint timeout_ms; GCancellable *cancellable; } BudgetTimer;
|
||
|
||
static gpointer budget_timer_run(gpointer data) {
|
||
BudgetTimer *timer=data; gint64 deadline=g_get_monotonic_time()+
|
||
(gint64)timer->timeout_ms*1000;
|
||
g_mutex_lock(&timer->mutex);
|
||
while(!timer->finished && g_cond_wait_until(&timer->condition,&timer->mutex,deadline));
|
||
gboolean expired=!timer->finished; g_mutex_unlock(&timer->mutex);
|
||
if (expired)
|
||
g_cancellable_cancel(timer->cancellable);
|
||
return NULL;
|
||
}
|
||
|
||
static void worker_stop_handler(int signal_number) {
|
||
(void)signal_number;
|
||
worker_stop_requested = 1;
|
||
local_tool_runner_request_process_cancel();
|
||
}
|
||
|
||
static char *workspace_path(const char *root, const char *leaf) {
|
||
return g_build_filename(root, leaf, NULL);
|
||
}
|
||
|
||
static char *now_iso(void) {
|
||
GDateTime *now = g_date_time_new_now_utc();
|
||
char *value = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ");
|
||
g_date_time_unref(now);
|
||
return value;
|
||
}
|
||
|
||
static gboolean export_correlations(const char *root, Database *database,
|
||
const char *investigation_id,
|
||
GError **error) {
|
||
LocalCorrelationLimits limits = {2000U, 500U, 1000U, 1024U * 1024U};
|
||
GBytes *snapshot = local_correlation_service_build(
|
||
database, investigation_id, limits, error);
|
||
char *path = workspace_path(root, "correlation-snapshot.json");
|
||
gboolean ok = snapshot != NULL &&
|
||
local_correlation_snapshot_write_atomic(snapshot, path, error);
|
||
g_clear_pointer(&snapshot, g_bytes_unref);
|
||
g_free(path);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean export_planner(const char *root, Database *database,
|
||
LocalJobStore *store,
|
||
LocalCapabilityRegistry *registry,
|
||
const char *investigation_id, GError **error) {
|
||
GBytes *snapshot = local_planner_service_build(database, store, registry,
|
||
investigation_id, 1024U * 1024U, error);
|
||
char *path = workspace_path(root, "planner-snapshot.json");
|
||
gboolean ok = snapshot != NULL &&
|
||
local_planner_snapshot_write_atomic(snapshot, path, error);
|
||
g_clear_pointer(&snapshot, g_bytes_unref); g_free(path); return ok;
|
||
}
|
||
|
||
static gboolean prepare_report(const char *root, const char *objects_csv,
|
||
const char *title, const char *comment, const char *generated_at,
|
||
const char *sections,
|
||
GError **error) {
|
||
if (objects_csv == NULL || title == NULL || generated_at == NULL) {
|
||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Paramètres du rapport incomplets."); return FALSE;
|
||
}
|
||
gchar **objects=g_strsplit(objects_csv,",",65);gsize count=g_strv_length(objects);
|
||
Manifest manifest={0};gboolean ok=count>0U&&count<=64U&&manifest_read(root,&manifest,error);
|
||
char *database_path=workspace_path(root,"Enquete.sqlite");
|
||
Database *database=ok?database_open_read_only(database_path,error):NULL;
|
||
gboolean evidence=sections!=NULL&&strstr(sections,"evidence")!=NULL;
|
||
gboolean timeline=sections!=NULL&&strstr(sections,"timeline")!=NULL;
|
||
gboolean infrastructure=sections!=NULL&&strstr(sections,"infrastructure")!=NULL;
|
||
if(!evidence&&!timeline&&!infrastructure){g_set_error_literal(error,G_IO_ERROR,
|
||
G_IO_ERROR_INVALID_ARGUMENT,"Sections de rapport invalides.");ok=FALSE;}
|
||
LocalReportRequest request={title,comment,"MINIMAL",generated_at,
|
||
(const char *const *)objects,count,evidence,timeline,infrastructure};
|
||
GBytes *document=database!=NULL?local_report_service_build(database,
|
||
manifest.investigation_id,&request,(LocalReportLimits){64U,256U,256U,
|
||
1024U*1024U},error):NULL;
|
||
if(document!=NULL){gsize size=0U;const char *data=g_bytes_get_data(document,&size);
|
||
ok=fwrite(data,1,size,stdout)==size&&fputc('\n',stdout)!=EOF;}else ok=FALSE;
|
||
g_clear_pointer(&document,g_bytes_unref);database_close(database);
|
||
g_free(database_path);manifest_clear(&manifest);g_strfreev(objects);return ok;
|
||
}
|
||
|
||
static char *derived_uuid(const char *key, const char *purpose) {
|
||
char *input = g_strconcat(key, ":", purpose, NULL);
|
||
char *hash = g_compute_checksum_for_string(G_CHECKSUM_SHA256, input, -1);
|
||
char *uuid = g_strdup_printf("%.8s-%.4s-4%.3s-8%.3s-%.12s", hash, hash + 8,
|
||
hash + 12, hash + 15, hash + 18);
|
||
g_free(hash);
|
||
g_free(input);
|
||
return uuid;
|
||
}
|
||
|
||
static void manifest_clear(Manifest *manifest) {
|
||
g_free(manifest->investigation_id);
|
||
g_free(manifest->eml_id);
|
||
g_free(manifest->image_id);
|
||
g_free(manifest->title);
|
||
memset(manifest, 0, sizeof(*manifest));
|
||
}
|
||
|
||
static gboolean manifest_write(const char *root, const Manifest *manifest,
|
||
GError **error) {
|
||
char *path = workspace_path(root, ".labfy/runtime/specimen.json");
|
||
char *data = g_strdup_printf(
|
||
"{\"contract\":\"labfy.local_jobs.specimen.v1\",\"synthetic\":true,"
|
||
"\"snapshot_file\":\"core-snapshot.json\","
|
||
"\"snapshot_contract\":\"labfy.web_graph.snapshot.v3\","
|
||
"\"investigation_id\":\"%s\",\"eml_id\":\"%s\","
|
||
"\"image_id\":\"%s\"}\n",
|
||
manifest->investigation_id, manifest->eml_id, manifest->image_id);
|
||
gboolean ok =
|
||
g_file_set_contents(path, data, -1, error) && g_chmod(path, 0600) == 0;
|
||
g_free(data);
|
||
g_free(path);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean manifest_read(const char *root, Manifest *manifest,
|
||
GError **error) {
|
||
char *path = workspace_path(root, ".labfy/runtime/workspace.json");
|
||
gboolean generic = g_file_test(path, G_FILE_TEST_IS_REGULAR);
|
||
if (!generic) {
|
||
g_free(path);
|
||
path = workspace_path(root, ".labfy/runtime/specimen.json");
|
||
}
|
||
JsonParser *parser = json_parser_new();
|
||
gboolean ok = json_parser_load_from_file(parser, path, error);
|
||
if (ok) {
|
||
JsonObject *object = json_node_get_object(json_parser_get_root(parser));
|
||
ok = object != NULL && g_strcmp0(json_object_get_string_member(object,
|
||
"contract"), generic ? "labfy.local_workspace.v1"
|
||
: "labfy.local_jobs.specimen.v1") == 0;
|
||
if (ok) {
|
||
manifest->investigation_id =
|
||
g_strdup(json_object_get_string_member(object, "investigation_id"));
|
||
manifest->synthetic = !generic;
|
||
manifest->title = g_strdup(generic
|
||
? json_object_get_string_member(object, "title") : "SPECIMEN J5");
|
||
if (!generic) {
|
||
manifest->eml_id = g_strdup(json_object_get_string_member(object, "eml_id"));
|
||
manifest->image_id = g_strdup(json_object_get_string_member(object, "image_id"));
|
||
}
|
||
ok = g_uuid_string_is_valid(manifest->investigation_id) &&
|
||
manifest->title != NULL && manifest->title[0] != '\0';
|
||
}
|
||
}
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Contexte d’espace local invalide.");
|
||
g_object_unref(parser);
|
||
g_free(path);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean create_workspace(const char *root, const char *title,
|
||
GError **error) {
|
||
if (title == NULL || !g_utf8_validate(title, -1, NULL) ||
|
||
title[0] == '\0' || strlen(title) > 160U) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Titre d’enquête invalide.");
|
||
return FALSE;
|
||
}
|
||
char *runtime = workspace_path(root, ".labfy/runtime");
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
char *manifest_path = workspace_path(root, ".labfy/runtime/workspace.json");
|
||
char *lock_path = workspace_path(root, ".labfy/lifecycle.lock");
|
||
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
|
||
char *snapshot_path = workspace_path(root, "core-snapshot.json");
|
||
char *correlation_path = workspace_path(root, "correlation-snapshot.json");
|
||
char *planner_path = workspace_path(root, "planner-snapshot.json");
|
||
gboolean database_existed = g_file_test(database_path, G_FILE_TEST_EXISTS);
|
||
gboolean manifest_existed = g_file_test(manifest_path, G_FILE_TEST_EXISTS);
|
||
gboolean jobs_existed = g_file_test(jobs_path, G_FILE_TEST_EXISTS);
|
||
gboolean snapshot_existed = g_file_test(snapshot_path, G_FILE_TEST_EXISTS);
|
||
gboolean correlation_existed = g_file_test(correlation_path, G_FILE_TEST_EXISTS);
|
||
gboolean planner_existed = g_file_test(planner_path, G_FILE_TEST_EXISTS);
|
||
gboolean database_owned = FALSE;
|
||
gboolean manifest_owned = FALSE;
|
||
gboolean jobs_owned = FALSE;
|
||
gboolean snapshot_owned = FALSE;
|
||
gboolean correlation_owned = FALSE;
|
||
gboolean planner_owned = FALSE;
|
||
int lock_fd = -1;
|
||
/* WHY: an obvious partial workspace is rejected before even creating the
|
||
* lifecycle-lock parent. The same predicate is evaluated again under the
|
||
* lock below to close the interprocess admission window. */
|
||
if (database_existed || manifest_existed || jobs_existed ||
|
||
snapshot_existed || correlation_existed || planner_existed) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_EXISTS,
|
||
"L’espace est déjà créé ou partiellement créé.");
|
||
g_free(planner_path);g_free(correlation_path);g_free(snapshot_path);
|
||
g_free(jobs_path);g_free(lock_path);g_free(manifest_path);
|
||
g_free(database_path);g_free(runtime);
|
||
return FALSE;
|
||
}
|
||
gboolean ok = g_mkdir_with_parents(runtime, 0700) == 0;
|
||
if (ok) lock_fd = open(lock_path, O_CREAT | O_RDWR, 0600);
|
||
if (lock_fd >= 0) (void)fcntl(lock_fd, F_SETFD, FD_CLOEXEC);
|
||
if (lock_fd < 0 || flock(lock_fd, LOCK_EX | LOCK_NB) != 0) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_BUSY,
|
||
"Un propriétaire utilise déjà le cycle de vie de cet espace.");
|
||
ok = FALSE;
|
||
}
|
||
/* CONTRACT: every managed artifact participates in the creation admission.
|
||
* An orphan store or export is evidence of a partial/foreign workspace, not
|
||
* scratch space that a new creation may open, truncate or replace. */
|
||
if (ok && (g_file_test(database_path, G_FILE_TEST_EXISTS) ||
|
||
g_file_test(manifest_path, G_FILE_TEST_EXISTS) ||
|
||
g_file_test(jobs_path, G_FILE_TEST_EXISTS) ||
|
||
g_file_test(snapshot_path, G_FILE_TEST_EXISTS) ||
|
||
g_file_test(correlation_path, G_FILE_TEST_EXISTS) ||
|
||
g_file_test(planner_path, G_FILE_TEST_EXISTS))) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_EXISTS,
|
||
"L’espace est déjà créé ou partiellement créé.");
|
||
ok = FALSE;
|
||
}
|
||
if (ok) {
|
||
ok = database_initialize(database_path, title, root);
|
||
database_owned = !database_existed &&
|
||
g_file_test(database_path, G_FILE_TEST_IS_REGULAR);
|
||
}
|
||
Database *database = ok ? database_open(database_path) : NULL;
|
||
InvestigationRecord *record = database != NULL
|
||
? investigation_dao_load(database) : NULL;
|
||
const char *id = record != NULL ? investigation_record_get_id(record) : NULL;
|
||
JsonBuilder *builder = ok ? json_builder_new() : NULL;
|
||
JsonGenerator *generator = NULL;
|
||
JsonNode *node = NULL;
|
||
char *data = NULL;
|
||
if (ok && database != NULL && id != NULL) {
|
||
json_builder_begin_object(builder);
|
||
json_builder_set_member_name(builder,"contract");json_builder_add_string_value(builder,"labfy.local_workspace.v1");
|
||
json_builder_set_member_name(builder,"version");json_builder_add_int_value(builder,1);
|
||
json_builder_set_member_name(builder,"investigation_id");json_builder_add_string_value(builder,id);
|
||
json_builder_set_member_name(builder,"title");json_builder_add_string_value(builder,title);
|
||
json_builder_set_member_name(builder,"mode");json_builder_add_string_value(builder,"local_experimental");
|
||
json_builder_set_member_name(builder,"database");json_builder_add_string_value(builder,"Enquete.sqlite");
|
||
json_builder_end_object(builder);
|
||
generator=json_generator_new();node=json_builder_get_root(builder);
|
||
json_generator_set_root(generator,node);data=json_generator_to_data(generator,NULL);
|
||
ok = data != NULL && write_private_atomic(manifest_path, data, error);
|
||
manifest_owned = ok && !manifest_existed;
|
||
} else if (ok) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"La base créée ne fournit pas d’identité d’enquête.");
|
||
ok = FALSE;
|
||
}
|
||
LocalJobStore *store = ok ? local_job_store_create(jobs_path, id, error) : NULL;
|
||
jobs_owned = !jobs_existed && g_file_test(jobs_path, G_FILE_TEST_IS_REGULAR);
|
||
ok = ok && store != NULL;
|
||
if (ok) {
|
||
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *snapshot = registry != NULL
|
||
? core_graph_projection_service_collect_operational(database, limits,
|
||
registry, error)
|
||
: NULL;
|
||
ok = snapshot != NULL && core_graph_snapshot_write_atomic(
|
||
snapshot, FALSE, snapshot_path, error) &&
|
||
export_correlations(root, database, id, error) &&
|
||
export_planner(root, database, store, registry, id, error);
|
||
snapshot_owned = !snapshot_existed &&
|
||
g_file_test(snapshot_path, G_FILE_TEST_IS_REGULAR);
|
||
correlation_owned = !correlation_existed &&
|
||
g_file_test(correlation_path, G_FILE_TEST_IS_REGULAR);
|
||
planner_owned = !planner_existed &&
|
||
g_file_test(planner_path, G_FILE_TEST_IS_REGULAR);
|
||
core_graph_snapshot_free(snapshot);
|
||
local_capability_registry_free(registry);
|
||
}
|
||
local_job_store_close(store); g_free(data);
|
||
if (node != NULL) json_node_free(node);
|
||
g_clear_object(&generator);g_clear_object(&builder);
|
||
investigation_record_free(record);
|
||
database_close(database);
|
||
/* OWNERSHIP: failure compensation may remove only artifacts created by this
|
||
* invocation. A refusal on an existing or partial workspace owns nothing. */
|
||
if (!ok && manifest_owned) (void)g_remove(manifest_path);
|
||
if (!ok && database_owned) (void)g_remove(database_path);
|
||
if (!ok && jobs_owned) (void)g_remove(jobs_path);
|
||
if (!ok && snapshot_owned) (void)g_remove(snapshot_path);
|
||
if (!ok && correlation_owned) (void)g_remove(correlation_path);
|
||
if (!ok && planner_owned) (void)g_remove(planner_path);
|
||
if (lock_fd >= 0) { (void)flock(lock_fd, LOCK_UN); close(lock_fd); }
|
||
g_free(planner_path);g_free(correlation_path);g_free(snapshot_path);
|
||
g_free(jobs_path);g_free(lock_path);g_free(manifest_path);
|
||
g_free(database_path);g_free(runtime);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean write_private_atomic(const char *path, const char *contents,
|
||
GError **error) {
|
||
GFile *file = g_file_new_for_path(path);
|
||
gboolean ok = g_file_replace_contents(file, contents, strlen(contents), NULL,
|
||
FALSE, G_FILE_CREATE_PRIVATE, NULL, NULL, error);
|
||
if (ok && g_chmod(path, 0600) != 0) {
|
||
g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno),
|
||
"Permissions privées impossibles pour %s.", path);
|
||
ok = FALSE;
|
||
}
|
||
g_object_unref(file);
|
||
return ok;
|
||
}
|
||
|
||
static char *import_intent_json(const Manifest *manifest, const char *upload_id,
|
||
const char *key, const char *evidence_id, const char *original_name,
|
||
const char *type, guint64 size, const char *sha256, const char *source,
|
||
const char *description) {
|
||
JsonBuilder *builder = json_builder_new();
|
||
json_builder_begin_object(builder);
|
||
#define ADD_STRING(name, value) do { json_builder_set_member_name(builder, name); \
|
||
json_builder_add_string_value(builder, value); } while (0)
|
||
ADD_STRING("contract", "labfy.local_import.intent.v1");
|
||
json_builder_set_member_name(builder, "version");
|
||
json_builder_add_int_value(builder, 1);
|
||
ADD_STRING("investigation_id", manifest->investigation_id);
|
||
ADD_STRING("upload_id", upload_id);
|
||
ADD_STRING("idempotency_key", key);
|
||
ADD_STRING("evidence_id", evidence_id);
|
||
ADD_STRING("original_name", original_name);
|
||
ADD_STRING("type", type);
|
||
json_builder_set_member_name(builder, "size");
|
||
json_builder_add_int_value(builder, (gint64)size);
|
||
ADD_STRING("sha256", sha256);
|
||
ADD_STRING("source", source != NULL ? source : "");
|
||
ADD_STRING("description", description != NULL ? description : "");
|
||
#undef ADD_STRING
|
||
json_builder_end_object(builder);
|
||
JsonGenerator *generator = json_generator_new();
|
||
JsonNode *node = json_builder_get_root(builder);
|
||
json_generator_set_root(generator, node);
|
||
char *result = json_generator_to_data(generator, NULL);
|
||
json_node_free(node);
|
||
g_object_unref(generator);
|
||
g_object_unref(builder);
|
||
return result;
|
||
}
|
||
|
||
static gboolean receipt_matches(const char *path, const char *intent_hash,
|
||
const char *evidence_id, char **out_state,
|
||
GError **error) {
|
||
JsonParser *parser = json_parser_new();
|
||
gboolean ok = json_parser_load_from_file(parser, path, error);
|
||
JsonObject *object = ok ? json_node_get_object(json_parser_get_root(parser)) : NULL;
|
||
const char *contract = object != NULL
|
||
? json_object_get_string_member_with_default(object, "contract", NULL) : NULL;
|
||
const char *saved_hash = object != NULL
|
||
? json_object_get_string_member_with_default(object, "intent_sha256", NULL) : NULL;
|
||
const char *saved_id = object != NULL
|
||
? json_object_get_string_member_with_default(object, "evidence_id", NULL) : NULL;
|
||
const char *state = object != NULL
|
||
? json_object_get_string_member_with_default(object, "state", NULL) : NULL;
|
||
ok = ok && g_strcmp0(contract, "labfy.local_import.receipt.v1") == 0 &&
|
||
g_strcmp0(saved_hash, intent_hash) == 0 &&
|
||
g_strcmp0(saved_id, evidence_id) == 0 &&
|
||
(g_strcmp0(state, "CONFIRMING") == 0 ||
|
||
g_strcmp0(state, "IMPORTED") == 0);
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Reçu d’import tronqué, incohérent ou contradictoire.");
|
||
if (ok && out_state != NULL) *out_state = g_strdup(state);
|
||
g_object_unref(parser);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean upload_intent_matches(const char *path,
|
||
const Manifest *manifest, const char *upload_id, const char *key,
|
||
const char *evidence_id, const char *original_name, const char *type,
|
||
guint64 expected_size, const char *sha256, const char *source,
|
||
const char *description, GError **error) {
|
||
if (g_file_test(path, G_FILE_TEST_IS_SYMLINK) ||
|
||
!g_file_test(path, G_FILE_TEST_IS_REGULAR)) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Intention durable d’import absente ou non régulière.");
|
||
return FALSE;
|
||
}
|
||
JsonParser *parser = json_parser_new();
|
||
gboolean loaded = json_parser_load_from_file(parser, path, error);
|
||
JsonObject *object = loaded
|
||
? json_node_get_object(json_parser_get_root(parser)) : NULL;
|
||
JsonObject *confirmation = object != NULL &&
|
||
json_object_has_member(object, "confirmation") &&
|
||
JSON_NODE_HOLDS_OBJECT(json_object_get_member(object, "confirmation"))
|
||
? json_object_get_object_member(object, "confirmation") : NULL;
|
||
#define MEMBER(value, name) ((value) != NULL ? \
|
||
json_object_get_string_member_with_default((value), (name), NULL) : NULL)
|
||
const char *state = MEMBER(object, "state");
|
||
gint64 saved_size = object != NULL
|
||
? json_object_get_int_member_with_default(object, "expected_size", -1)
|
||
: -1;
|
||
gboolean ok = loaded && object != NULL && confirmation != NULL &&
|
||
g_strcmp0(MEMBER(object, "contract"), "labfy.local_upload.v1") == 0 &&
|
||
g_strcmp0(MEMBER(object, "upload_id"), upload_id) == 0 &&
|
||
g_strcmp0(MEMBER(object, "workspace_id"), manifest->investigation_id) == 0 &&
|
||
g_strcmp0(MEMBER(object, "evidence_id"), evidence_id) == 0 &&
|
||
g_strcmp0(MEMBER(object, "name"), original_name) == 0 &&
|
||
g_strcmp0(MEMBER(object, "recognized_type"), type) == 0 &&
|
||
saved_size >= 0 && (guint64)saved_size == expected_size &&
|
||
g_strcmp0(MEMBER(object, "sha256"), sha256) == 0 &&
|
||
(g_strcmp0(state, "CONFIRMING") == 0 ||
|
||
g_strcmp0(state, "IMPORTED") == 0) &&
|
||
g_strcmp0(MEMBER(confirmation, "contract"),
|
||
"labfy.local_import.intent.v1") == 0 &&
|
||
g_strcmp0(MEMBER(confirmation, "idempotency_key"), key) == 0 &&
|
||
g_strcmp0(MEMBER(confirmation, "source"), source != NULL ? source : "") == 0 &&
|
||
g_strcmp0(MEMBER(confirmation, "description"),
|
||
description != NULL ? description : "") == 0;
|
||
#undef MEMBER
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Intention durable d’import incohérente ou contradictoire.");
|
||
g_object_unref(parser);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean validate_published_evidence(const char *root,
|
||
const EvidenceRecord *record, const char *evidence_id,
|
||
const char *original_name, const char *type, guint64 expected_size,
|
||
const char *sha256, const char *source, const char *description,
|
||
GError **error) {
|
||
const char *relative = evidence_record_get_relative_path(record);
|
||
const char *saved_source = evidence_record_get_source(record);
|
||
const char *saved_description = evidence_record_get_description(record);
|
||
gboolean metadata_ok =
|
||
g_strcmp0(evidence_record_get_identifier(record), evidence_id) == 0 &&
|
||
g_strcmp0(evidence_record_get_original_name(record), original_name) == 0 &&
|
||
g_strcmp0(evidence_record_get_type_identifier(record), type) == 0 &&
|
||
evidence_record_get_size_bytes(record) == expected_size &&
|
||
g_ascii_strcasecmp(evidence_record_get_sha256(record), sha256) == 0 &&
|
||
g_strcmp0(saved_source != NULL ? saved_source : "",
|
||
source != NULL ? source : "") == 0 &&
|
||
g_strcmp0(saved_description != NULL ? saved_description : "",
|
||
description != NULL ? description : "") == 0;
|
||
if (!metadata_ok || relative == NULL || g_path_is_absolute(relative) ||
|
||
strstr(relative, "..") != NULL) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Record V20 incohérent avec l’intention durable.");
|
||
return FALSE;
|
||
}
|
||
char *published = g_build_filename(root, relative, NULL);
|
||
struct stat status = {0};
|
||
char *actual = NULL;
|
||
guint64 actual_size = 0U;
|
||
gboolean ok = !g_file_test(published, G_FILE_TEST_IS_SYMLINK) &&
|
||
stat(published, &status) == 0 && S_ISREG(status.st_mode) &&
|
||
file_hash_compute_sha256(published, NULL, &actual, &actual_size, error) &&
|
||
actual_size == expected_size && g_ascii_strcasecmp(actual, sha256) == 0;
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Original publié absent, spécial, symbolique ou altéré.");
|
||
g_free(actual);
|
||
g_free(published);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean confirm_import(const char *root, const char *upload_id,
|
||
const char *key, const char *evidence_id, const char *original_name,
|
||
const char *type, const char *size_text, const char *sha256,
|
||
const char *source, const char *description, gboolean crash_after_commit,
|
||
GError **error) {
|
||
if (!g_uuid_string_is_valid(upload_id) || !g_uuid_string_is_valid(key) ||
|
||
!g_uuid_string_is_valid(evidence_id) || original_name == NULL ||
|
||
(g_strcmp0(type, "email") != 0 && g_strcmp0(type, "photo") != 0) ||
|
||
sha256 == NULL || strlen(sha256) != 64U) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Intention d’import invalide."); return FALSE;
|
||
}
|
||
char *end = NULL; guint64 expected_size = g_ascii_strtoull(size_text, &end, 10);
|
||
if (end == size_text || *end != '\0' || expected_size > 4U * 1024U * 1024U) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Taille d’import invalide."); return FALSE;
|
||
}
|
||
Manifest manifest = {0};
|
||
if (!manifest_read(root, &manifest, error)) return FALSE;
|
||
char *staged = g_build_filename(root, ".labfy", "uploads", upload_id,
|
||
"payload", NULL);
|
||
char *upload_intent = g_build_filename(root, ".labfy", "uploads", upload_id,
|
||
"intent.json", NULL);
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
char *receipt_dir = workspace_path(root, ".labfy/imports");
|
||
char *key_hash = g_compute_checksum_for_string(G_CHECKSUM_SHA256, key, -1);
|
||
char *receipt = g_build_filename(receipt_dir, key_hash, NULL);
|
||
char *canonical = import_intent_json(&manifest, upload_id, key, evidence_id,
|
||
original_name, type, expected_size, sha256, source, description);
|
||
char *intent = canonical != NULL
|
||
? g_compute_checksum_for_string(G_CHECKSUM_SHA256, canonical, -1) : NULL;
|
||
/* INVARIANT: deux confirmations concurrentes du même espace observent le
|
||
* reçu ou la preuve publiée par leur prédécesseur ; elles ne copient jamais
|
||
* simultanément le même UUID réservé. */
|
||
char *import_lock_path=workspace_path(root,".labfy/import.lock");
|
||
int import_lock=open(import_lock_path,O_CREAT|O_RDWR,0600);
|
||
if(import_lock<0||flock(import_lock,LOCK_EX)!=0){
|
||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_BUSY,
|
||
"Verrou de publication indisponible.");
|
||
if (import_lock >= 0)
|
||
close(import_lock);
|
||
g_free(import_lock_path);
|
||
manifest_clear(&manifest);g_free(upload_intent);g_free(staged);
|
||
g_free(canonical);g_free(intent);
|
||
g_free(database_path);g_free(receipt_dir);g_free(key_hash);g_free(receipt);
|
||
return FALSE;
|
||
}
|
||
Database *database = database_open(database_path);
|
||
EvidenceDao *dao = database != NULL ? evidence_dao_new(database, error) : NULL;
|
||
InvestigationRecord *investigation = database != NULL
|
||
? investigation_dao_load(database) : NULL;
|
||
gboolean ok = canonical != NULL && intent != NULL && database != NULL &&
|
||
dao != NULL && investigation != NULL &&
|
||
g_strcmp0(investigation_record_get_id(investigation),
|
||
manifest.investigation_id) == 0 &&
|
||
g_mkdir_with_parents(receipt_dir, 0700) == 0;
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Identité métier de l’espace incohérente.");
|
||
/* INVARIANT: argv is only transport. The atomically published upload intent
|
||
* is the authority for every identifier and every free-text field across a
|
||
* server/process crash; no receipt or business row precedes this comparison. */
|
||
if (ok)
|
||
ok = upload_intent_matches(upload_intent, &manifest, upload_id, key,
|
||
evidence_id, original_name, type, expected_size, sha256, source,
|
||
description, error);
|
||
char *receipt_state = NULL;
|
||
if (ok && g_file_test(receipt, G_FILE_TEST_EXISTS))
|
||
ok = g_file_test(receipt, G_FILE_TEST_IS_REGULAR) &&
|
||
receipt_matches(receipt, intent, evidence_id, &receipt_state, error);
|
||
else if (ok) {
|
||
char *receipt_data = g_strdup_printf(
|
||
"{\"contract\":\"labfy.local_import.receipt.v1\","
|
||
"\"evidence_id\":\"%s\",\"intent_sha256\":\"%s\","
|
||
"\"state\":\"CONFIRMING\",\"version\":1}\n",
|
||
evidence_id, intent);
|
||
ok = write_private_atomic(receipt, receipt_data, error);
|
||
g_free(receipt_data);
|
||
if (ok) receipt_state = g_strdup("CONFIRMING");
|
||
}
|
||
/* INVARIANT: the record lookup occurs after acquiring the publication lock;
|
||
* no stale pre-lock object may decide whether an import already committed. */
|
||
EvidenceRecord *existing = ok
|
||
? evidence_dao_find_by_identifier(dao, evidence_id, error) : NULL;
|
||
if (ok && existing != NULL) {
|
||
ok = validate_published_evidence(root, existing, evidence_id, original_name,
|
||
type, expected_size, sha256, source, description, error);
|
||
} else if (ok && (error == NULL || *error == NULL)) {
|
||
char *actual = NULL;
|
||
guint64 actual_size = 0U;
|
||
ok = file_hash_compute_sha256(staged, NULL, &actual, &actual_size, error) &&
|
||
actual_size == expected_size &&
|
||
g_ascii_strcasecmp(actual, sha256) == 0;
|
||
if (!ok && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
|
||
"Empreinte du fichier préparé incohérente.");
|
||
g_free(actual);
|
||
}
|
||
if (ok && existing == NULL) {
|
||
char *folder = g_strcmp0(type, "email") == 0 ? "Emails" : "Images";
|
||
char *destination = g_build_filename(root, "01_Preuves_Originales", folder, NULL);
|
||
char *relative = g_build_filename("01_Preuves_Originales", folder, NULL);
|
||
EvidenceImporter *importer = evidence_importer_new(database, error);
|
||
EvidenceImportRequest request = {.source_path=staged,
|
||
.destination_directory=destination,.relative_directory=relative,
|
||
.type_identifier=type,.reserved_identifier=evidence_id,
|
||
.original_name=original_name,.collected_at=NULL,
|
||
.source=(source != NULL && source[0] != '\0') ? source : NULL,
|
||
.description=(description != NULL && description[0] != '\0') ? description : NULL};
|
||
ok = importer != NULL && g_mkdir_with_parents(destination, 0700) == 0;
|
||
EvidenceRecord *record = ok ? evidence_importer_import(importer,&request,NULL,error):NULL;
|
||
ok = record != NULL && validate_published_evidence(root, record, evidence_id,
|
||
original_name, type, expected_size, sha256, source, description, error);
|
||
evidence_record_free(record); evidence_importer_free(importer);
|
||
g_free(relative); g_free(destination);
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
if (ok && crash_after_commit)
|
||
_exit(87);
|
||
#else
|
||
(void)crash_after_commit;
|
||
#endif
|
||
}
|
||
if (ok) {
|
||
char *receipt_data = g_strdup_printf(
|
||
"{\"contract\":\"labfy.local_import.receipt.v1\","
|
||
"\"evidence_id\":\"%s\",\"intent_sha256\":\"%s\","
|
||
"\"state\":\"IMPORTED\",\"version\":1}\n",
|
||
evidence_id, intent);
|
||
ok = write_private_atomic(receipt, receipt_data, error);
|
||
g_free(receipt_data);
|
||
}
|
||
gboolean projection_ok = TRUE;
|
||
if (ok) {
|
||
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
|
||
CoreGraphLimits limits={500U,1000U,1024U*1024U};
|
||
CoreGraphSnapshot *snapshot=registry!=NULL?core_graph_projection_service_collect_operational(database,limits,registry,error):NULL;
|
||
char *path=workspace_path(root,"core-snapshot.json");
|
||
Manifest store_manifest = {0};
|
||
LocalJobStore *store=open_store(root,&store_manifest,error);
|
||
projection_ok=snapshot!=NULL&&store!=NULL&&core_graph_snapshot_write_atomic(snapshot,FALSE,path,error)&&
|
||
export_correlations(root,database,manifest.investigation_id,error)&&
|
||
export_planner(root,database,store,registry,manifest.investigation_id,error);
|
||
local_job_store_close(store);manifest_clear(&store_manifest);
|
||
g_free(path);core_graph_snapshot_free(snapshot);
|
||
local_capability_registry_free(registry);
|
||
}
|
||
/* CONTRACT: snapshot export is retryable presentation work. Once V20 and the
|
||
* receipt agree, an export failure cannot turn the import back into pending. */
|
||
if (ok && !projection_ok) g_clear_error(error);
|
||
if (ok) printf("{\"contract\":\"labfy.local_import.result.v1\","
|
||
"\"state\":\"IMPORTED\",\"evidence_id\":\"%s\","
|
||
"\"projection_available\":%s}\n", evidence_id,
|
||
projection_ok ? "true" : "false");
|
||
evidence_record_free(existing);investigation_record_free(investigation);
|
||
evidence_dao_free(dao);database_close(database);
|
||
(void)flock(import_lock,LOCK_UN);close(import_lock);g_free(import_lock_path);
|
||
g_free(receipt_state);g_free(canonical);g_free(intent);g_free(receipt);
|
||
g_free(key_hash);g_free(receipt_dir);g_free(database_path);g_free(staged);
|
||
g_free(upload_intent);
|
||
manifest_clear(&manifest);
|
||
return ok;
|
||
}
|
||
|
||
static EvidenceRecord *import_file(Database *database, const char *root,
|
||
const char *source, const char *folder,
|
||
const char *type, GError **error) {
|
||
char *destination =
|
||
g_build_filename(root, "01_Preuves_Originales", folder, NULL);
|
||
EvidenceImporter *importer = evidence_importer_new(database, error);
|
||
EvidenceImportRequest request = {
|
||
.source_path = source,
|
||
.destination_directory = destination,
|
||
.relative_directory = g_strconcat("01_Preuves_Originales/", folder, NULL),
|
||
.type_identifier = type,
|
||
.collected_at = timestamp,
|
||
.source = "Générateur SPECIMEN J5",
|
||
.description = "Preuve fictive pour reprise locale bornée."};
|
||
EvidenceRecord *record = NULL;
|
||
if (importer != NULL && g_mkdir_with_parents(destination, 0700) == 0)
|
||
record = evidence_importer_import(importer, &request, NULL, error);
|
||
g_free((char *)request.relative_directory);
|
||
evidence_importer_free(importer);
|
||
g_free(destination);
|
||
return record;
|
||
}
|
||
|
||
static gboolean init_specimen(const char *root, GError **error) {
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
char *runtime = workspace_path(root, ".labfy/runtime");
|
||
char *input = workspace_path(root, "SPECIMEN_INPUT");
|
||
char *eml_path = g_build_filename(input, "message-j5.eml", NULL);
|
||
char *image_path = g_build_filename(input, "image-j5.png", NULL);
|
||
gboolean ok = !g_file_test(database_path, G_FILE_TEST_EXISTS) &&
|
||
g_mkdir_with_parents(runtime, 0700) == 0 &&
|
||
g_mkdir_with_parents(input, 0700) == 0 &&
|
||
g_file_set_contents(eml_path, eml_content, -1, error);
|
||
gsize png_size = 0;
|
||
guchar *png = g_base64_decode(png_base64, &png_size);
|
||
ok = ok && png != NULL &&
|
||
g_file_set_contents(image_path, (char *)png, (gssize)png_size, error);
|
||
g_free(png);
|
||
ok = ok && database_initialize(database_path, "SPECIMEN J5", root);
|
||
LocalCapabilityRegistry *registry =
|
||
ok ? local_capability_registry_new(error) : NULL;
|
||
const LocalCapabilityStatus *exif =
|
||
registry != NULL ? local_capability_registry_lookup(
|
||
registry, LOCAL_CAPABILITY_EXIF_METADATA)
|
||
: NULL;
|
||
const char *argv[] = {exif != NULL ? exif->executable : "",
|
||
"-config",
|
||
"",
|
||
"-overwrite_original",
|
||
"-XMP-dc:Creator=Alice SPECIMEN",
|
||
"--",
|
||
image_path,
|
||
NULL};
|
||
LocalToolRunnerResult *tool_result = NULL;
|
||
ok = ok && exif != NULL && exif->availability == LOCAL_CAPABILITY_READY &&
|
||
local_tool_runner_run(exif->executable, argv, input,
|
||
&exif->descriptor->limits, NULL, &tool_result,
|
||
error) &&
|
||
tool_result->state == LOCAL_TOOL_RUNNER_EXITED;
|
||
local_tool_runner_result_free(tool_result);
|
||
Database *database = ok ? database_open(database_path) : NULL;
|
||
EvidenceRecord *eml = database != NULL ? import_file(database, root, eml_path,
|
||
"Emails", "email", error)
|
||
: NULL;
|
||
EvidenceRecord *image = eml != NULL ? import_file(database, root, image_path,
|
||
"Images", "photo", error)
|
||
: NULL;
|
||
InvestigationRecord *investigation =
|
||
image != NULL ? investigation_dao_load(database) : NULL;
|
||
Manifest manifest = {0};
|
||
if (investigation != NULL) {
|
||
manifest.investigation_id =
|
||
g_strdup(investigation_record_get_id(investigation));
|
||
manifest.eml_id = g_strdup(evidence_record_get_identifier(eml));
|
||
manifest.image_id = g_strdup(evidence_record_get_identifier(image));
|
||
}
|
||
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
|
||
LocalJobStore *store =
|
||
investigation != NULL
|
||
? local_job_store_create(jobs_path, manifest.investigation_id, error)
|
||
: NULL;
|
||
ok = store != NULL && manifest_write(root, &manifest, error);
|
||
if (ok) {
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *snapshot =
|
||
core_graph_projection_service_collect_operational(database, limits,
|
||
registry, error);
|
||
char *snapshot_path = workspace_path(root, "core-snapshot.json");
|
||
ok = snapshot != NULL &&
|
||
core_graph_snapshot_write_atomic(snapshot, TRUE, snapshot_path, error);
|
||
g_free(snapshot_path);
|
||
core_graph_snapshot_free(snapshot);
|
||
if (ok)
|
||
ok = export_correlations(root, database, manifest.investigation_id, error);
|
||
}
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
investigation_record_free(investigation);
|
||
evidence_record_free(image);
|
||
evidence_record_free(eml);
|
||
database_close(database);
|
||
local_capability_registry_free(registry);
|
||
g_free(jobs_path);
|
||
g_free(image_path);
|
||
g_free(eml_path);
|
||
g_free(input);
|
||
g_free(runtime);
|
||
g_free(database_path);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean init_j7_specimen(const char *root, GError **error) {
|
||
if (!init_specimen(root, error)) return FALSE;
|
||
char *input = workspace_path(root, "SPECIMEN_INPUT");
|
||
const char *names[] = {"message-j7-a.eml", "message-j7-b.eml",
|
||
"message-j7-c.eml", "message-j7-b-copy.eml"};
|
||
const char *contents[] = {j7_eml_a, j7_eml_b, j7_eml_c, j7_eml_b};
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = database_open(database_path);
|
||
GPtrArray *identifiers = g_ptr_array_new_with_free_func(g_free);
|
||
gboolean ok = database != NULL;
|
||
for (guint i = 0; ok && i < G_N_ELEMENTS(names); i++) {
|
||
char *path = g_build_filename(input, names[i], NULL);
|
||
ok = g_file_set_contents(path, contents[i], -1, error);
|
||
EvidenceRecord *record = ok ? import_file(database, root, path, "Emails",
|
||
"email", error) : NULL;
|
||
ok = record != NULL;
|
||
if (record != NULL)
|
||
g_ptr_array_add(identifiers,
|
||
g_strdup(evidence_record_get_identifier(record)));
|
||
evidence_record_free(record); g_free(path);
|
||
}
|
||
Manifest manifest = {0};
|
||
if (ok) ok = manifest_read(root, &manifest, error);
|
||
if (ok) {
|
||
JsonBuilder *builder = json_builder_new();
|
||
json_builder_begin_object(builder);
|
||
json_builder_set_member_name(builder, "contract");
|
||
json_builder_add_string_value(builder, "labfy.j7.specimen.v1");
|
||
json_builder_set_member_name(builder, "synthetic");
|
||
json_builder_add_boolean_value(builder, TRUE);
|
||
json_builder_set_member_name(builder, "evidence_ids");
|
||
json_builder_begin_array(builder);
|
||
json_builder_add_string_value(builder, manifest.eml_id);
|
||
json_builder_add_string_value(builder, manifest.image_id);
|
||
for (guint i = 0; i < identifiers->len; i++)
|
||
json_builder_add_string_value(builder, g_ptr_array_index(identifiers, i));
|
||
json_builder_end_array(builder); json_builder_end_object(builder);
|
||
JsonGenerator *generator = json_generator_new();
|
||
JsonNode *node = json_builder_get_root(builder);
|
||
json_generator_set_root(generator, node);
|
||
char *data = json_generator_to_data(generator, NULL);
|
||
char *path = workspace_path(root, ".labfy/runtime/j7-specimen.json");
|
||
ok = data != NULL && g_file_set_contents(path, data, -1, error) &&
|
||
g_chmod(path, 0600) == 0;
|
||
g_free(path); g_free(data); json_node_free(node);
|
||
g_object_unref(generator); g_object_unref(builder);
|
||
}
|
||
if (ok) {
|
||
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *graph = registry != NULL
|
||
? core_graph_projection_service_collect_operational(database, limits,
|
||
registry, error)
|
||
: NULL;
|
||
char *graph_path = workspace_path(root, "core-snapshot.json");
|
||
ok = graph != NULL && core_graph_snapshot_write_atomic(
|
||
graph, TRUE, graph_path, error) && export_correlations(
|
||
root, database, manifest.investigation_id, error);
|
||
g_free(graph_path); core_graph_snapshot_free(graph);
|
||
local_capability_registry_free(registry);
|
||
}
|
||
manifest_clear(&manifest); g_ptr_array_unref(identifiers);
|
||
database_close(database); g_free(database_path); g_free(input);
|
||
return ok;
|
||
}
|
||
|
||
static LocalJobStore *open_store(const char *root, Manifest *manifest,
|
||
GError **error) {
|
||
if (!manifest_read(root, manifest, error))
|
||
return NULL;
|
||
char *path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
|
||
LocalJobStore *store =
|
||
local_job_store_open(path, manifest->investigation_id, FALSE, error);
|
||
g_free(path);
|
||
return store;
|
||
}
|
||
|
||
static gboolean enqueue_jobs(const char *root, GError **error) {
|
||
Manifest manifest = {0};
|
||
LocalJobStore *store = open_store(root, &manifest, error);
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = store != NULL ? database_open(database_path) : NULL;
|
||
EvidenceDao *dao =
|
||
database != NULL ? evidence_dao_new(database, error) : NULL;
|
||
EvidenceRecord *eml =
|
||
dao != NULL ? evidence_dao_find_by_identifier(dao, manifest.eml_id, error)
|
||
: NULL;
|
||
EvidenceRecord *image =
|
||
dao != NULL
|
||
? evidence_dao_find_by_identifier(dao, manifest.image_id, error)
|
||
: NULL;
|
||
LocalJobSubmission eml_job = {EML_JOB,
|
||
EML_REQUEST,
|
||
manifest.eml_id,
|
||
EML_DERIVATIVE,
|
||
LOCAL_CAPABILITY_EML_HEADERS,
|
||
EML_ANALYSIS_TOOL_ID,
|
||
EML_ANALYSIS_TOOL_VERSION,
|
||
eml ? evidence_record_get_sha256(eml) : NULL,
|
||
eml ? evidence_record_get_size_bytes(eml) : 0,
|
||
timestamp,
|
||
"{\"mode\":\"headers\"}",
|
||
NULL,
|
||
3};
|
||
LocalJobSubmission exif_job = {
|
||
EXIF_JOB,
|
||
EXIF_REQUEST,
|
||
manifest.image_id,
|
||
EXIF_DERIVATIVE,
|
||
LOCAL_CAPABILITY_EXIF_METADATA,
|
||
"exiftool-json",
|
||
"1",
|
||
image ? evidence_record_get_sha256(image) : NULL,
|
||
image ? evidence_record_get_size_bytes(image) : 0,
|
||
timestamp,
|
||
"{\"groups\":\"G1\",\"numeric_values\":true}",
|
||
EML_JOB,
|
||
3};
|
||
gboolean reused = FALSE;
|
||
gboolean ok = eml != NULL && image != NULL &&
|
||
local_job_store_enqueue(store, &eml_job, &reused, error) &&
|
||
local_job_store_enqueue(store, &exif_job, &reused, error);
|
||
evidence_record_free(image);
|
||
evidence_record_free(eml);
|
||
evidence_dao_free(dao);
|
||
database_close(database);
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
g_free(database_path);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean submit_job(const char *root, const char *evidence_id,
|
||
const char *capability_id, const char *key,
|
||
char **out_job_id, GError **error) {
|
||
if (!g_uuid_string_is_valid(evidence_id) || !g_uuid_string_is_valid(key) ||
|
||
(g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) != 0 &&
|
||
g_strcmp0(capability_id, LOCAL_CAPABILITY_EXIF_METADATA) != 0)) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Intention d'analyse inconnue ou invalide.");
|
||
return FALSE;
|
||
}
|
||
Manifest manifest = {0};
|
||
LocalJobStore *store = open_store(root, &manifest, error);
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = store != NULL ? database_open(database_path) : NULL;
|
||
EvidenceDao *dao =
|
||
database != NULL ? evidence_dao_new(database, error) : NULL;
|
||
EvidenceRecord *evidence =
|
||
dao != NULL ? evidence_dao_find_by_identifier(dao, evidence_id, error)
|
||
: NULL;
|
||
LocalCapabilityRegistry *registry =
|
||
evidence != NULL ? local_capability_registry_new(error) : NULL;
|
||
const LocalCapabilityStatus *status =
|
||
registry != NULL
|
||
? local_capability_registry_lookup(registry, capability_id)
|
||
: NULL;
|
||
char *reason = NULL;
|
||
const char *type =
|
||
evidence != NULL ? evidence_record_get_type_identifier(evidence) : NULL;
|
||
const char *mime = g_strcmp0(type, "email") == 0 ? "message/rfc822"
|
||
: g_strcmp0(type, "photo") == 0
|
||
? "image/unknown"
|
||
: "application/octet-stream";
|
||
gboolean applicable =
|
||
status != NULL &&
|
||
local_capability_status_applies(
|
||
status, mime, evidence_record_get_relative_path(evidence), &reason);
|
||
if (!applicable || status->availability != LOCAL_CAPABILITY_READY) {
|
||
g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
|
||
"Capability indisponible pour cette preuve : %s",
|
||
reason != NULL ? reason
|
||
: status != NULL ? status->reason
|
||
: "capability absente");
|
||
}
|
||
char *request_id = derived_uuid(key, "request");
|
||
char *derivative_id = derived_uuid(key, "derivative");
|
||
char *requested_at = now_iso();
|
||
LocalJobSubmission submission = {
|
||
.job_id = key,
|
||
.request_id = request_id,
|
||
.source_evidence_id = evidence_id,
|
||
.derivative_evidence_id = derivative_id,
|
||
.capability_id = capability_id,
|
||
.adapter_id = g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0
|
||
? EML_ANALYSIS_TOOL_ID
|
||
: "exiftool-json",
|
||
.adapter_version = "1",
|
||
.source_sha256 =
|
||
evidence != NULL ? evidence_record_get_sha256(evidence) : NULL,
|
||
.source_size =
|
||
evidence != NULL ? evidence_record_get_size_bytes(evidence) : 0,
|
||
.requested_at = requested_at,
|
||
.parameters_json =
|
||
g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0
|
||
? "{\"mode\":\"headers\"}"
|
||
: "{\"groups\":\"G1\",\"numeric_values\":true}",
|
||
.max_attempts = 3};
|
||
gboolean reused = FALSE;
|
||
gboolean ok = evidence != NULL && applicable &&
|
||
status->availability == LOCAL_CAPABILITY_READY &&
|
||
local_job_store_enqueue(store, &submission, &reused, error);
|
||
if (ok && out_job_id != NULL)
|
||
*out_job_id = g_strdup(key);
|
||
g_free(requested_at);
|
||
g_free(derivative_id);
|
||
g_free(request_id);
|
||
g_free(reason);
|
||
local_capability_registry_free(registry);
|
||
evidence_record_free(evidence);
|
||
evidence_dao_free(dao);
|
||
database_close(database);
|
||
g_free(database_path);
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean submit_plan(const char *root, const char *revision,
|
||
const char *profile, const char *key,
|
||
const char *ids_csv, gboolean crash_after_admission,
|
||
GError **error) {
|
||
if (!revision || !profile || !g_uuid_string_is_valid(key) || !ids_csv) {
|
||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Intention de plan invalide."); return FALSE;
|
||
}
|
||
char *planner_path=workspace_path(root,"planner-snapshot.json"); JsonParser *parser=json_parser_new();
|
||
gboolean ok=json_parser_load_from_file(parser,planner_path,error); g_free(planner_path);
|
||
JsonObject *root_object=ok?json_node_get_object(json_parser_get_root(parser)):NULL;
|
||
if(ok && g_strcmp0(json_object_get_string_member(root_object,"input_revision"),revision)!=0){
|
||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Projection planner périmée.");ok=FALSE;}
|
||
gchar **ids=g_strsplit(ids_csv,",",9); gsize count=g_strv_length(ids);
|
||
guint max_analyses=g_strcmp0(profile,"SPECIMEN_SMALL")==0?2U:g_strcmp0(profile,"LOCAL_PRUDENT")==0?8U:0U;
|
||
guint max_attempts=g_strcmp0(profile,"SPECIMEN_SMALL")==0?3U:16U;
|
||
guint64 max_bytes=g_strcmp0(profile,"SPECIMEN_SMALL")==0?8U*1024U*1024U:256U*1024U*1024U;
|
||
guint64 max_ms=g_strcmp0(profile,"SPECIMEN_SMALL")==0?30000U:240000U;
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
const char *test_max_ms = g_getenv("LABFY_TEST_PLAN_ACTIVE_MS");
|
||
if (test_max_ms != NULL && test_max_ms[0] != '\0')
|
||
max_ms = g_ascii_strtoull(test_max_ms, NULL, 10);
|
||
#endif
|
||
if(ok && (count==0||count>max_analyses)){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NO_SPACE,"Profil de budget insuffisant.");ok=FALSE;}
|
||
Manifest manifest={0}; LocalJobStore *store=ok?open_store(root,&manifest,error):NULL;
|
||
char *database_path=workspace_path(root,"Enquete.sqlite"); Database *database=store?database_open(database_path):NULL;
|
||
EvidenceDao *dao=database?evidence_dao_new(database,error):NULL; LocalCapabilityRegistry *registry=dao?local_capability_registry_new(error):NULL;
|
||
LocalJobSubmission *jobs=g_new0(LocalJobSubmission,count); GPtrArray *owned=g_ptr_array_new_with_free_func(g_free);
|
||
JsonArray *recommendations=ok?json_object_get_array_member(root_object,"recommendations"):NULL;
|
||
for(gsize i=0;ok&&i<count;i++){
|
||
JsonObject *match=NULL; for(guint j=0;j<json_array_get_length(recommendations);j++){JsonObject *candidate=json_array_get_object_element(recommendations,j);if(g_strcmp0(json_object_get_string_member(candidate,"id"),ids[i])==0){match=candidate;break;}}
|
||
if(!match||!json_object_get_boolean_member(match,"available")){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_FOUND,"Recommandation absente ou indisponible.");ok=FALSE;break;}
|
||
const char *evidence_id=json_object_get_string_member(match,"object_id"); const char *cap=json_object_get_string_member(match,"capability_id"); EvidenceRecord *evidence=evidence_dao_find_by_identifier(dao,evidence_id,error); const LocalCapabilityStatus *status=local_capability_registry_lookup(registry,cap);
|
||
if(!evidence||!status||status->availability!=LOCAL_CAPABILITY_READY||g_strcmp0(evidence_record_get_sha256(evidence),json_object_get_string_member(match,"source_sha256"))!=0){evidence_record_free(evidence);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Précondition de recommandation modifiée.");ok=FALSE;break;}
|
||
char *seed=g_strconcat(key,":",ids[i],NULL);char *job=derived_uuid(seed,"job"),*request=derived_uuid(seed,"request"),*derivative=derived_uuid(seed,"derivative");g_free(seed);char *at=now_iso();g_ptr_array_add(owned,job);g_ptr_array_add(owned,request);g_ptr_array_add(owned,derivative);g_ptr_array_add(owned,at);
|
||
jobs[i]=(LocalJobSubmission){job,request,evidence_id,derivative,cap,status->descriptor->adapter_id,status->descriptor->adapter_version,evidence_record_get_sha256(evidence),evidence_record_get_size_bytes(evidence),at,g_strcmp0(cap,LOCAL_CAPABILITY_EML_HEADERS)==0?"{\"mode\":\"headers\"}":"{\"groups\":\"G1\",\"numeric_values\":true}",NULL,g_strcmp0(profile,"SPECIMEN_SMALL")==0?1U:2U};
|
||
/* Les champs empruntés au record doivent survivre jusqu'à l'admission. */
|
||
jobs[i].source_evidence_id=g_strdup(evidence_id);jobs[i].source_sha256=g_strdup(evidence_record_get_sha256(evidence));g_ptr_array_add(owned,(gpointer)jobs[i].source_evidence_id);g_ptr_array_add(owned,(gpointer)jobs[i].source_sha256);evidence_record_free(evidence);
|
||
}
|
||
char *plan_id=derived_uuid(key,"plan");GString *intent=g_string_new(revision);g_string_append(intent,profile);for(gsize i=0;i<count;i++)g_string_append(intent,ids[i]);char *intent_hash=g_compute_checksum_for_string(G_CHECKSUM_SHA256,intent->str,-1);char *created=now_iso();LocalPlanAdmission plan={plan_id,key,intent_hash,revision,profile,created,max_analyses,max_attempts,max_bytes,max_ms};gboolean reused=FALSE;
|
||
if(ok)ok=local_job_store_admit_plan(store,&plan,jobs,count,&reused,error);
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
if (ok && crash_after_admission) {
|
||
/* TEST CONTRACT: le marqueur est émis seulement après le COMMIT durable;
|
||
* _exit reproduit une réponse perdue sans exécuter le premier claim. */
|
||
(void)write(STDERR_FILENO, "J8_BARRIER_ADMISSION_COMMITTED\n", 31);
|
||
_exit(85);
|
||
}
|
||
#else
|
||
(void)crash_after_admission;
|
||
#endif
|
||
if(ok){printf("{\"contract\":\"labfy.local_plan.v1\",\"accepted\":true,\"reused\":%s,\"plan_id\":\"%s\",\"jobs\":[",reused?"true":"false",plan_id);for(gsize i=0;i<count;i++)printf("%s\"%s\"",i?",":"",jobs[i].job_id);printf("]}\n");}
|
||
g_free(created);g_free(intent_hash);g_string_free(intent,TRUE);g_free(plan_id);g_free(jobs);g_ptr_array_unref(owned);local_capability_registry_free(registry);evidence_dao_free(dao);database_close(database);g_free(database_path);local_job_store_close(store);manifest_clear(&manifest);g_strfreev(ids);g_object_unref(parser);return ok;
|
||
}
|
||
|
||
static gboolean lookup_plan(const char *root,const char *revision,
|
||
const char *profile,const char *key,
|
||
const char *ids_csv,GError **error) {
|
||
Manifest manifest={0};LocalJobStore *store=open_store(root,&manifest,error);
|
||
if (!store)
|
||
return FALSE;
|
||
GString *intent=g_string_new(revision);g_string_append(intent,profile);
|
||
gchar **ids=g_strsplit(ids_csv,",",9);for(guint i=0;ids[i];i++)g_string_append(intent,ids[i]);
|
||
char *hash=g_compute_checksum_for_string(G_CHECKSUM_SHA256,intent->str,-1),*plan_id=NULL;
|
||
gboolean ok=local_job_store_find_plan_intention(store,key,hash,&plan_id,error);
|
||
if(ok&&plan_id)printf("{\"contract\":\"labfy.local_plan.v1\",\"found\":true,\"reused\":true,\"plan_id\":\"%s\"}\n",plan_id);
|
||
else if(ok)printf("{\"contract\":\"labfy.local_plan.v1\",\"found\":false}\n");
|
||
g_free(plan_id);g_free(hash);g_strfreev(ids);g_string_free(intent,TRUE);local_job_store_close(store);manifest_clear(&manifest);return ok;
|
||
}
|
||
|
||
static gboolean execute_job(const char *root, Database *database,
|
||
LocalCapabilityRegistry *registry,
|
||
LocalJobRecord *job, GCancellable *cancellable,
|
||
GError **error) {
|
||
if (g_strcmp0(job->capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0) {
|
||
EmlAnalysisPersistenceService *service =
|
||
eml_analysis_persistence_service_new(database, root, error);
|
||
EmlAnalysisPersistenceRequest request = {
|
||
job->request_id, job->source_evidence_id, job->derivative_evidence_id,
|
||
job->requested_at};
|
||
EmlAnalysisPrepared *prepared =
|
||
service != NULL ? eml_analysis_persistence_service_prepare(
|
||
service, &request, cancellable, error)
|
||
: NULL;
|
||
EmlAnalysisPublicationResult *result =
|
||
prepared != NULL ? eml_analysis_persistence_service_publish(
|
||
service, prepared, cancellable, error)
|
||
: NULL;
|
||
gboolean ok = result != NULL;
|
||
eml_analysis_publication_result_free(result);
|
||
eml_analysis_prepared_free(prepared);
|
||
eml_analysis_persistence_service_free(service);
|
||
return ok;
|
||
}
|
||
if (g_strcmp0(job->capability_id, LOCAL_CAPABILITY_EXIF_METADATA) != 0) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
|
||
"Capability de job inconnue ; aucune exécution.");
|
||
return FALSE;
|
||
}
|
||
ExiftoolPersistenceService *service =
|
||
exiftool_persistence_service_new(database, root, registry, error);
|
||
ExiftoolPersistenceRequest request = {
|
||
job->request_id, job->source_evidence_id, job->derivative_evidence_id,
|
||
job->requested_at};
|
||
ExiftoolPublicationResult *result =
|
||
service != NULL
|
||
? exiftool_persistence_service_execute(service, &request, cancellable, error)
|
||
: NULL;
|
||
gboolean ok = result != NULL;
|
||
exiftool_publication_result_free(result);
|
||
exiftool_persistence_service_free(service);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean run_worker(const char *root, guint crash_point,
|
||
GError **error) {
|
||
struct sigaction stop_action = {0};
|
||
struct sigaction previous_term = {0};
|
||
struct sigaction previous_int = {0};
|
||
stop_action.sa_handler = worker_stop_handler;
|
||
sigemptyset(&stop_action.sa_mask);
|
||
worker_stop_requested = 0;
|
||
local_tool_runner_reset_process_cancel();
|
||
(void)sigaction(SIGTERM, &stop_action, &previous_term);
|
||
(void)sigaction(SIGINT, &stop_action, &previous_int);
|
||
Manifest manifest = {0};
|
||
LocalJobStore *store = open_store(root, &manifest, error);
|
||
char *lock_path = workspace_path(root, ".labfy/runtime/worker.lock");
|
||
int lock_fd = open(lock_path, O_CREAT | O_RDWR, 0600);
|
||
if (lock_fd >= 0)
|
||
(void)fcntl(lock_fd, F_SETFD, FD_CLOEXEC);
|
||
if (store == NULL || lock_fd < 0 || flock(lock_fd, LOCK_EX | LOCK_NB) != 0) {
|
||
if (error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_BUSY,
|
||
"Un worker possède déjà cet espace.");
|
||
local_job_store_close(store);
|
||
if (lock_fd >= 0)
|
||
close(lock_fd);
|
||
g_free(lock_path);
|
||
manifest_clear(&manifest);
|
||
return FALSE;
|
||
}
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = database_open(database_path);
|
||
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
|
||
GPtrArray *jobs = local_job_store_list(store, error);
|
||
for (guint index = 0; jobs != NULL && index < jobs->len; index++) {
|
||
LocalJobRecord *job = g_ptr_array_index(jobs, index);
|
||
if (job->state == LOCAL_JOB_RUNNING) {
|
||
ExtractionDao *extraction_dao = extraction_dao_new(database, NULL);
|
||
ExtractionRecord *published =
|
||
extraction_dao != NULL ? extraction_dao_find_by_identifier(
|
||
extraction_dao, job->request_id, NULL)
|
||
: NULL;
|
||
extraction_dao_free(extraction_dao);
|
||
GError *replay_error = NULL;
|
||
if (published != NULL &&
|
||
execute_job(root, database, registry, job, NULL, &replay_error)) {
|
||
char *now = now_iso();
|
||
local_job_store_reconcile_interrupted(
|
||
store, job->job_id, LOCAL_JOB_COMPLETED, "recovered_after_publish",
|
||
"published", now, error);
|
||
g_free(now);
|
||
} else if (published == NULL && job->cancel_requested) {
|
||
char *now = now_iso();
|
||
local_job_store_reconcile_interrupted(
|
||
store, job->job_id, LOCAL_JOB_CANCELLED,
|
||
"cancelled_after_worker_stop", "cancelled", now, error);
|
||
g_free(now);
|
||
} else if (published == NULL) {
|
||
char *now = now_iso();
|
||
local_job_store_requeue_interrupted(
|
||
store, job->job_id, "interrupted_before_publish", now, error);
|
||
g_free(now);
|
||
} else {
|
||
char *now = now_iso();
|
||
local_job_store_reconcile_interrupted(
|
||
store, job->job_id, LOCAL_JOB_RECOVERY_REQUIRED,
|
||
replay_error != NULL ? replay_error->message : "résultat ambigu",
|
||
"blocked", now, error);
|
||
g_free(now);
|
||
}
|
||
extraction_record_free(published);
|
||
g_clear_error(&replay_error);
|
||
}
|
||
}
|
||
g_clear_pointer(&jobs, g_ptr_array_unref);
|
||
while ((error == NULL || *error == NULL) && !worker_stop_requested) {
|
||
char *owner = g_uuid_string_random();
|
||
char *attempt = g_uuid_string_random();
|
||
char *now = now_iso();
|
||
LocalJobRecord *job = NULL;
|
||
guint reserved_active_ms = 0;
|
||
guint requested_active_ms = 30000U;
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
const char *test_active_ms = g_getenv("LABFY_TEST_PLAN_ACTIVE_MS");
|
||
if (test_active_ms != NULL && test_active_ms[0] != '\0')
|
||
requested_active_ms = (guint)g_ascii_strtoull(test_active_ms, NULL, 10);
|
||
#endif
|
||
gboolean ok =
|
||
local_job_store_claim_next_budgeted(store, owner, attempt, now,
|
||
requested_active_ms, &reserved_active_ms, &job, error);
|
||
g_free(now);
|
||
g_free(attempt);
|
||
if (!ok || job == NULL) {
|
||
g_free(owner);
|
||
break;
|
||
}
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
if (crash_point == 1U) {
|
||
(void)write(STDERR_FILENO, "J8_BARRIER_CLAIM_COMMITTED\n", 27);
|
||
_exit(84);
|
||
}
|
||
#endif
|
||
GError *execution_error = NULL;
|
||
GCancellable *cancellable = g_cancellable_new();
|
||
BudgetTimer timer = {.finished=FALSE,.timeout_ms=reserved_active_ms,
|
||
.cancellable=cancellable};
|
||
g_mutex_init(&timer.mutex);g_cond_init(&timer.condition);
|
||
GThread *timer_thread=g_thread_new("plan-budget",budget_timer_run,&timer);
|
||
gint64 started_us=g_get_monotonic_time();
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
const char *test_delay = g_getenv("LABFY_TEST_TOOL_DELAY_MS");
|
||
if (test_delay != NULL && test_delay[0] != '\0') {
|
||
char *marker = workspace_path(root, ".labfy/runtime/test-tool-started");
|
||
char *pid = g_strdup_printf("%ld\n", (long)getpid());
|
||
(void)g_file_set_contents(marker, pid, -1, NULL);
|
||
guint delay_ms = (guint)g_ascii_strtoull(test_delay, NULL, 10);
|
||
for (guint waited = 0U; waited < delay_ms &&
|
||
!g_cancellable_is_cancelled(cancellable) &&
|
||
!worker_stop_requested; waited += 10U)
|
||
g_usleep(10U * 1000U);
|
||
g_free(pid); g_free(marker);
|
||
}
|
||
#endif
|
||
if (g_cancellable_is_cancelled(cancellable) || worker_stop_requested) {
|
||
g_set_error_literal(&execution_error, G_IO_ERROR, G_IO_ERROR_CANCELLED,
|
||
"Outil synthétique interrompu dans sa limite.");
|
||
ok = FALSE;
|
||
} else
|
||
ok = execute_job(root, database, registry, job, cancellable, &execution_error);
|
||
gint64 elapsed_us=g_get_monotonic_time()-started_us;
|
||
g_mutex_lock(&timer.mutex);timer.finished=TRUE;g_cond_signal(&timer.condition);
|
||
g_mutex_unlock(&timer.mutex);g_thread_join(timer_thread);
|
||
g_cond_clear(&timer.condition);g_mutex_clear(&timer.mutex);
|
||
guint elapsed_ms=(guint)MIN((guint64)G_MAXUINT,
|
||
(guint64)MAX((gint64)1,(elapsed_us+999)/1000));
|
||
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
|
||
if (ok && crash_point == 2U)
|
||
_exit(86);
|
||
#else
|
||
(void)crash_point;
|
||
#endif
|
||
now = now_iso();
|
||
const char *diagnostic = ok ? "published"
|
||
: execution_error != NULL
|
||
? execution_error->message
|
||
: "execution_failed";
|
||
GError *finish_error = NULL;
|
||
gboolean budget_expired = !worker_stop_requested &&
|
||
g_cancellable_is_cancelled(cancellable);
|
||
LocalJobState terminal_state = worker_stop_requested && !ok
|
||
? LOCAL_JOB_CANCELLED : budget_expired ? LOCAL_JOB_BLOCKED
|
||
: ok ? LOCAL_JOB_COMPLETED : LOCAL_JOB_FAILED;
|
||
gboolean recorded = local_job_store_finish_budgeted(
|
||
store, job->job_id, owner, terminal_state,
|
||
worker_stop_requested && !ok ? "cancelled_after_tool_cleanup"
|
||
: budget_expired ? "active_time_limit" : diagnostic,
|
||
worker_stop_requested && !ok ? "cancelled"
|
||
: budget_expired ? "limit_reached" : ok ? "published" : "failed",
|
||
now, elapsed_ms, &finish_error);
|
||
g_free(now);
|
||
local_job_record_free(job);
|
||
g_free(owner);
|
||
if (!recorded && error != NULL && *error == NULL)
|
||
g_propagate_error(error, finish_error);
|
||
else
|
||
g_clear_error(&finish_error);
|
||
g_clear_error(&execution_error);
|
||
g_object_unref(cancellable);
|
||
if (!recorded)
|
||
break;
|
||
char *jobs_export = workspace_path(root, "jobs-snapshot.json");
|
||
local_job_store_export_atomic(store, jobs_export, NULL);
|
||
g_free(jobs_export);
|
||
if (ok) {
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *current =
|
||
core_graph_projection_service_collect_operational(database, limits,
|
||
registry, NULL);
|
||
char *graph_export = workspace_path(root, "core-snapshot.json");
|
||
if (current != NULL)
|
||
(void)core_graph_snapshot_write_atomic(current, manifest.synthetic, graph_export,
|
||
NULL);
|
||
g_free(graph_export);
|
||
core_graph_snapshot_free(current);
|
||
(void)export_correlations(root, database, manifest.investigation_id, NULL);
|
||
(void)export_planner(root, database, store, registry,
|
||
manifest.investigation_id, NULL);
|
||
}
|
||
}
|
||
jobs = local_job_store_list(store, NULL);
|
||
gboolean complete = TRUE;
|
||
for (guint i = 0; jobs != NULL && i < jobs->len; i++)
|
||
complete =
|
||
complete && ((LocalJobRecord *)g_ptr_array_index(jobs, i))->state ==
|
||
LOCAL_JOB_COMPLETED;
|
||
g_clear_pointer(&jobs, g_ptr_array_unref);
|
||
char *export_path = workspace_path(root, "jobs-snapshot.json");
|
||
local_job_store_export_atomic(store, export_path, NULL);
|
||
g_free(export_path);
|
||
if (complete) {
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *snapshot =
|
||
core_graph_projection_service_collect_operational(database, limits,
|
||
registry, error);
|
||
char *snapshot_path = workspace_path(root, "core-snapshot.json");
|
||
if (snapshot == NULL ||
|
||
!core_graph_snapshot_write_atomic(snapshot, manifest.synthetic, snapshot_path, error))
|
||
complete = FALSE;
|
||
g_free(snapshot_path);
|
||
core_graph_snapshot_free(snapshot);
|
||
}
|
||
local_capability_registry_free(registry);
|
||
database_close(database);
|
||
g_free(database_path);
|
||
flock(lock_fd, LOCK_UN);
|
||
close(lock_fd);
|
||
g_free(lock_path);
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
(void)sigaction(SIGTERM, &previous_term, NULL);
|
||
(void)sigaction(SIGINT, &previous_int, NULL);
|
||
return complete && (error == NULL || *error == NULL);
|
||
}
|
||
|
||
static gboolean status_or_export(const char *root, gboolean print,
|
||
GError **error) {
|
||
Manifest manifest = {0};
|
||
LocalJobStore *store = open_store(root, &manifest, error);
|
||
GPtrArray *jobs = store != NULL ? local_job_store_list(store, error) : NULL;
|
||
for (guint i = 0; print && jobs != NULL && i < jobs->len; i++) {
|
||
LocalJobRecord *job = g_ptr_array_index(jobs, i);
|
||
printf("%s %s tentatives=%u %s\n", job->job_id,
|
||
local_job_state_code(job->state), job->attempt_count,
|
||
job->diagnostic != NULL ? job->diagnostic : "");
|
||
}
|
||
char *path = workspace_path(root, "jobs-snapshot.json");
|
||
gboolean ok =
|
||
jobs != NULL && local_job_store_export_atomic(store, path, error);
|
||
char *database_path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = ok ? database_open(database_path) : NULL;
|
||
LocalCapabilityRegistry *registry = ok ? local_capability_registry_new(error) : NULL;
|
||
if (ok)
|
||
ok = database != NULL && registry != NULL && export_correlations(
|
||
root, database, manifest.investigation_id, error) &&
|
||
export_planner(root, database, store, registry,
|
||
manifest.investigation_id, error);
|
||
local_capability_registry_free(registry);
|
||
database_close(database);
|
||
g_free(database_path);
|
||
g_free(path);
|
||
g_clear_pointer(&jobs, g_ptr_array_unref);
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
return ok;
|
||
}
|
||
|
||
static Database *open_workspace_database(const char *root, Manifest *manifest,
|
||
GError **error) {
|
||
if (!manifest_read(root, manifest, error)) return NULL;
|
||
char *path = workspace_path(root, "Enquete.sqlite");
|
||
Database *database = database_open(path);
|
||
g_free(path);
|
||
if (database == NULL) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||
"Impossible d'ouvrir la base de l'espace.");
|
||
manifest_clear(manifest);
|
||
}
|
||
return database;
|
||
}
|
||
|
||
static gboolean print_evidence_preview(const char *root,
|
||
const char *evidence_id,
|
||
GError **error) {
|
||
Manifest manifest = {0};
|
||
Database *database = NULL;
|
||
EvidenceDao *dao = NULL;
|
||
EvidenceRecord *evidence = NULL;
|
||
EvidencePreviewRequest *request = NULL;
|
||
EvidencePreviewResult *preview = NULL;
|
||
gboolean ok = FALSE;
|
||
if (!g_uuid_string_is_valid(evidence_id)) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"UUID de preuve invalide.");
|
||
return FALSE;
|
||
}
|
||
database = open_workspace_database(root, &manifest, error);
|
||
dao = database != NULL ? evidence_dao_new(database, error) : NULL;
|
||
evidence = dao != NULL
|
||
? evidence_dao_find_by_identifier(dao, evidence_id, error) : NULL;
|
||
if (evidence == NULL && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
|
||
"Preuve inconnue dans cet espace.");
|
||
if (evidence != NULL) request = evidence_preview_request_new(root, evidence_id,
|
||
evidence_record_get_relative_path(evidence),
|
||
evidence_record_get_sha256(evidence), NULL, 1U);
|
||
preview = request != NULL ? evidence_preview_load(request, NULL, error) : NULL;
|
||
if (preview != NULL) {
|
||
const char *kind = preview->kind == EVIDENCE_PREVIEW_KIND_EMAIL ? "email" :
|
||
preview->kind == EVIDENCE_PREVIEW_KIND_IMAGE ? "image" : "unsupported";
|
||
char *cache_input = g_strdup_printf("%s:%s:%s:web-v1:preview-v1",
|
||
manifest.investigation_id, evidence_id,
|
||
evidence_record_get_sha256(evidence));
|
||
char *cache_id = g_compute_checksum_for_string(G_CHECKSUM_SHA256,
|
||
cache_input, -1);
|
||
char *image = preview->png_bytes != NULL
|
||
? g_base64_encode(g_bytes_get_data(preview->png_bytes, NULL),
|
||
g_bytes_get_size(preview->png_bytes)) : NULL;
|
||
JsonBuilder *builder = json_builder_new();
|
||
json_builder_begin_object(builder);
|
||
json_add_nullable(builder, "contract", "labfy.evidence_preview.v1");
|
||
json_add_nullable(builder, "evidence_id", evidence_id);
|
||
json_add_nullable(builder, "display_name",
|
||
evidence_record_get_original_name(evidence));
|
||
json_add_nullable(builder, "type", evidence_record_get_type_identifier(evidence));
|
||
json_add_nullable(builder, "sha256", evidence_record_get_sha256(evidence));
|
||
json_builder_set_member_name(builder, "size_bytes");
|
||
json_builder_add_int_value(builder, (gint64) evidence_record_get_size_bytes(evidence));
|
||
json_add_nullable(builder, "kind", kind);
|
||
json_add_nullable(builder, "cache_id", cache_id);
|
||
json_add_nullable(builder, "renderer_version", "preview-v1");
|
||
json_add_nullable(builder, "text", preview->text);
|
||
json_add_nullable(builder, "image_png_base64", image);
|
||
json_builder_set_member_name(builder, "width");
|
||
json_builder_add_int_value(builder, preview->width);
|
||
json_builder_set_member_name(builder, "height");
|
||
json_builder_add_int_value(builder, preview->height);
|
||
json_builder_set_member_name(builder, "truncated");
|
||
json_builder_add_boolean_value(builder, preview->truncated);
|
||
json_builder_set_member_name(builder, "integrity_valid");
|
||
json_builder_add_boolean_value(builder, preview->integrity_valid);
|
||
json_builder_end_object(builder);
|
||
ok = print_json_builder(builder, error);
|
||
g_object_unref(builder); g_free(image); g_free(cache_id); g_free(cache_input);
|
||
}
|
||
evidence_preview_result_free(preview);
|
||
evidence_preview_request_free(request);
|
||
evidence_record_free(evidence); evidence_dao_free(dao);
|
||
database_close(database); manifest_clear(&manifest);
|
||
return ok;
|
||
}
|
||
|
||
static guint64 observation_revision(Database *database, const char *id) {
|
||
DatabaseStatement *statement = database_statement_prepare(database,
|
||
"SELECT COUNT(*) FROM journal WHERE action='observation.review.v1' "
|
||
"AND objet_type='evidence_observation' AND objet_id=?;");
|
||
int64_t count = 0;
|
||
gboolean ok = statement != NULL &&
|
||
database_statement_bind_text(statement, 1, id) &&
|
||
database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW &&
|
||
database_statement_column_int64(statement, 0, &count) && count >= 0;
|
||
database_statement_finalize(statement);
|
||
return ok ? (guint64) count : 0U;
|
||
}
|
||
|
||
static gboolean print_observations(const char *root, const char *evidence_id,
|
||
const char *extraction_id, GError **error) {
|
||
Manifest manifest = {0};
|
||
Database *database = NULL; EvidenceDao *evidence_dao = NULL;
|
||
ExtractionDao *extraction_dao = NULL; ExtractionRecord *extraction = NULL;
|
||
EvidenceEntityDao *dao = NULL; EvidenceRecord *evidence = NULL;
|
||
GPtrArray *items = NULL; gboolean ok = FALSE;
|
||
if (!g_uuid_string_is_valid(evidence_id) ||
|
||
(extraction_id != NULL && !g_uuid_string_is_valid(extraction_id))) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"UUID de preuve ou d'extraction invalide.");
|
||
return FALSE;
|
||
}
|
||
database = open_workspace_database(root, &manifest, error);
|
||
evidence_dao = database != NULL ? evidence_dao_new(database, error) : NULL;
|
||
evidence = evidence_dao != NULL
|
||
? evidence_dao_find_by_identifier(evidence_dao, evidence_id, error) : NULL;
|
||
if (evidence == NULL && error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
|
||
"Preuve inconnue dans cet espace.");
|
||
extraction_dao = evidence != NULL && extraction_id != NULL
|
||
? extraction_dao_new(database, error) : NULL;
|
||
extraction = extraction_dao != NULL
|
||
? extraction_dao_find_by_identifier(extraction_dao, extraction_id, error)
|
||
: NULL;
|
||
if (extraction_id != NULL && extraction == NULL &&
|
||
error != NULL && *error == NULL)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
|
||
"Extraction inconnue dans cet espace.");
|
||
if (extraction != NULL &&
|
||
g_strcmp0(extraction->source_identifier, evidence_id) != 0 &&
|
||
g_strcmp0(extraction->evidence_identifier, evidence_id) != 0)
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
|
||
"L'extraction n'appartient pas à la preuve demandée.");
|
||
if (error != NULL && *error != NULL) {
|
||
evidence_record_free(evidence);
|
||
evidence = NULL;
|
||
}
|
||
dao = evidence != NULL ? evidence_entity_dao_new(database, error) : NULL;
|
||
items = dao != NULL
|
||
? evidence_entity_dao_list_observations(dao, evidence_id, error) : NULL;
|
||
if (items != NULL) {
|
||
JsonBuilder *builder = json_builder_new();
|
||
json_builder_begin_object(builder);
|
||
json_add_nullable(builder, "contract", "labfy.evidence_observations.v1");
|
||
json_add_nullable(builder, "evidence_id", evidence_id);
|
||
json_builder_set_member_name(builder, "observations");
|
||
json_builder_begin_array(builder);
|
||
for (guint i = 0; i < items->len; i++) {
|
||
EvidenceObservation *item = g_ptr_array_index(items, i);
|
||
if (extraction_id != NULL &&
|
||
g_strcmp0(item->extraction_identifier, extraction_id) != 0) continue;
|
||
json_builder_begin_object(builder);
|
||
json_add_nullable(builder, "id", item->identifier);
|
||
json_add_nullable(builder, "extraction_id", item->extraction_identifier);
|
||
json_add_nullable(builder, "entity_type", item->type_identifier);
|
||
json_add_nullable(builder, "value_raw", item->value_raw);
|
||
json_add_nullable(builder, "value_normalized", item->value_normalized);
|
||
json_add_nullable(builder, "value_corrected", item->value_corrected);
|
||
json_add_nullable(builder, "role", item->role);
|
||
json_add_nullable(builder, "source_header", item->source_header);
|
||
json_add_nullable(builder, "provenance_kind", item->provenance_kind);
|
||
json_add_nullable(builder, "verification_status", item->verification_status);
|
||
json_add_nullable(builder, "entity_id", item->entity_identifier);
|
||
json_builder_set_member_name(builder, "revision");
|
||
json_builder_add_int_value(builder,
|
||
(gint64) observation_revision(database, item->identifier));
|
||
json_builder_end_object(builder);
|
||
}
|
||
json_builder_end_array(builder); json_builder_end_object(builder);
|
||
ok = print_json_builder(builder, error); g_object_unref(builder);
|
||
}
|
||
g_clear_pointer(&items, g_ptr_array_unref); evidence_entity_dao_free(dao);
|
||
extraction_record_free(extraction); extraction_dao_free(extraction_dao);
|
||
evidence_record_free(evidence); evidence_dao_free(evidence_dao);
|
||
database_close(database); manifest_clear(&manifest); return ok;
|
||
}
|
||
|
||
static gboolean republish_after_review(const char *root, Database *database,
|
||
const Manifest *manifest,
|
||
GError **error) {
|
||
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
|
||
LocalJobStore *store = local_job_store_open(
|
||
jobs_path, manifest->investigation_id, FALSE, error);
|
||
g_free(jobs_path);
|
||
LocalCapabilityRegistry *registry = store != NULL
|
||
? local_capability_registry_new(error) : NULL;
|
||
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
|
||
CoreGraphSnapshot *snapshot = registry != NULL
|
||
? core_graph_projection_service_collect_operational(database, limits,
|
||
registry, error) : NULL;
|
||
char *path = workspace_path(root, "core-snapshot.json");
|
||
gboolean ok = snapshot != NULL && core_graph_snapshot_write_atomic(
|
||
snapshot, manifest->synthetic, path, error) && export_correlations(
|
||
root, database, manifest->investigation_id, error) && export_planner(
|
||
root, database, store, registry, manifest->investigation_id, error);
|
||
g_free(path); core_graph_snapshot_free(snapshot);
|
||
local_capability_registry_free(registry); local_job_store_close(store);
|
||
return ok;
|
||
}
|
||
|
||
static gboolean review_observation(const char *root, const char *evidence_id,
|
||
const char *observation_id, const char *operation_id, const char *revision,
|
||
const char *action, const char *status, const char *corrected,
|
||
const char *entity_id, const char *author, const char *reason,
|
||
GError **error) {
|
||
char *end = NULL; guint64 expected = 0U;
|
||
ObservationReviewAction mapped;
|
||
if (revision == NULL) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Révision invalide."); return FALSE;
|
||
}
|
||
expected = g_ascii_strtoull(revision, &end, 10);
|
||
if (end == revision || *end != '\0') {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Révision invalide."); return FALSE;
|
||
}
|
||
if (g_strcmp0(action, "decide") == 0) mapped = OBSERVATION_REVIEW_ACTION_DECIDE;
|
||
else if (g_strcmp0(action, "correct") == 0) mapped = OBSERVATION_REVIEW_ACTION_CORRECT;
|
||
else if (g_strcmp0(action, "promote_create") == 0) mapped = OBSERVATION_REVIEW_ACTION_PROMOTE_CREATE;
|
||
else if (g_strcmp0(action, "promote_attach") == 0) mapped = OBSERVATION_REVIEW_ACTION_PROMOTE_ATTACH;
|
||
else if (g_strcmp0(action, "withdraw") == 0) mapped = OBSERVATION_REVIEW_ACTION_WITHDRAW_PROMOTION;
|
||
else { g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||
"Action de revue invalide."); return FALSE; }
|
||
Manifest manifest = {0}; Database *database = open_workspace_database(
|
||
root, &manifest, error); ObservationReviewService *service = database != NULL
|
||
? observation_review_service_new(database, error) : NULL;
|
||
char *occurred_at = now_iso(); ObservationReviewResult result = {0};
|
||
ObservationReviewRequest request = {operation_id, evidence_id, observation_id,
|
||
expected, mapped, status, corrected, entity_id, author, reason, occurred_at};
|
||
gboolean ok = service != NULL && observation_review_service_apply(
|
||
service, &request, &result, error);
|
||
/* CONTRACT: publication refreshes local projections only; no analysis job,
|
||
* network access, budget admission or EML parsing is triggered here. */
|
||
if (ok) ok = republish_after_review(root, database, &manifest, error);
|
||
if (ok) {
|
||
JsonBuilder *builder = json_builder_new(); json_builder_begin_object(builder);
|
||
json_add_nullable(builder, "contract", "labfy.observation_review.command.v1");
|
||
json_builder_set_member_name(builder, "replayed");
|
||
json_builder_add_boolean_value(builder, result.replayed);
|
||
json_builder_set_member_name(builder, "revision");
|
||
json_builder_add_int_value(builder, (gint64) result.revision);
|
||
json_add_nullable(builder, "entity_id", result.entity_identifier);
|
||
json_builder_set_member_name(builder, "entity_created");
|
||
json_builder_add_boolean_value(builder, result.entity_created);
|
||
json_builder_set_member_name(builder, "projections_refreshed");
|
||
json_builder_add_boolean_value(builder, TRUE); json_builder_end_object(builder);
|
||
ok = print_json_builder(builder, error); g_object_unref(builder);
|
||
}
|
||
observation_review_result_clear(&result); g_free(occurred_at);
|
||
observation_review_service_free(service); database_close(database);
|
||
manifest_clear(&manifest); return ok;
|
||
}
|
||
|
||
static gboolean control(const char *root, const char *command,
|
||
const char *job_id, GError **error) {
|
||
Manifest manifest = {0};
|
||
LocalJobStore *store = open_store(root, &manifest, error);
|
||
char *now = now_iso();
|
||
gboolean ok = FALSE;
|
||
gboolean was_running = FALSE;
|
||
if (store != NULL && strcmp(command, "cancel") == 0) {
|
||
LocalJobRecord *target = local_job_store_find(store, job_id, error);
|
||
was_running = target != NULL && target->state == LOCAL_JOB_RUNNING;
|
||
local_job_record_free(target);
|
||
ok = (error == NULL || *error == NULL) &&
|
||
local_job_store_request_cancel(store, job_id, now, error);
|
||
if (ok)
|
||
printf("{\"contract\":\"labfy.local_jobs.command.v1\","
|
||
"\"accepted\":true,\"was_running\":%s}\n",
|
||
was_running ? "true" : "false");
|
||
}
|
||
else if (store != NULL && strcmp(command, "pause") == 0)
|
||
ok = local_job_store_set_paused(store, TRUE, now, error);
|
||
else if (store != NULL && strcmp(command, "resume") == 0)
|
||
ok = local_job_store_set_paused(store, FALSE, now, error) &&
|
||
local_job_store_set_stopped(store, FALSE, now, error);
|
||
else if (store != NULL && strcmp(command, "stop") == 0)
|
||
ok = local_job_store_request_stop(store, now, error);
|
||
g_free(now);
|
||
local_job_store_close(store);
|
||
manifest_clear(&manifest);
|
||
return ok;
|
||
}
|
||
|
||
static int demonstrate(const char *root, const char *self, GError **error) {
|
||
if (!init_specimen(root, error) || !enqueue_jobs(root, error))
|
||
return 1;
|
||
/* Deux processus réellement interrompus couvrent successivement les deux
|
||
* publications. Le successeur réouvre les mêmes fichiers et réconcilie le
|
||
* résultat typé avant de réclamer la demande suivante. */
|
||
for (guint crash = 0; crash < 2; crash++) {
|
||
pid_t child = fork();
|
||
if (child == 0) {
|
||
execl(self, self, "__test-crash", "--workspace", root, NULL);
|
||
_exit(127);
|
||
}
|
||
int status = 0;
|
||
if (child < 0 || waitpid(child, &status, 0) != child ||
|
||
!WIFEXITED(status) || WEXITSTATUS(status) != 86) {
|
||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
|
||
"Le point de crash contrôlé n'a pas été atteint.");
|
||
return 1;
|
||
}
|
||
}
|
||
if (!run_worker(root, 0U, error))
|
||
return 1;
|
||
printf("SPECIMEN J5 repris sur le même espace : %s\n", root);
|
||
return status_or_export(root, TRUE, error) ? 0 : 1;
|
||
}
|
||
|
||
int main(int argc, char **argv) {
|
||
const char *workspace = NULL;
|
||
for (int i = 2; i + 1 < argc; i++)
|
||
if (strcmp(argv[i], "--workspace") == 0)
|
||
workspace = argv[i + 1];
|
||
if (argc < 2 || workspace == NULL) {
|
||
fprintf(stderr, "Usage: %s COMMAND --workspace DIR [JOB_ID]\n", argv[0]);
|
||
return 2;
|
||
}
|
||
GError *error = NULL;
|
||
gboolean ok = FALSE;
|
||
if (strcmp(argv[1], "init-specimen") == 0)
|
||
ok = init_specimen(workspace, &error);
|
||
else if (strcmp(argv[1], "create-workspace") == 0) {
|
||
const char *title = NULL;
|
||
for (int i = 4; i + 1 < argc; i += 2)
|
||
if (strcmp(argv[i], "--title") == 0) title = argv[i + 1];
|
||
ok = create_workspace(workspace, title, &error);
|
||
} else if (strcmp(argv[1], "confirm-import-json") == 0 ||
|
||
strcmp(argv[1], "__test-crash-import-after-commit") == 0) {
|
||
const char *upload=NULL,*key=NULL,*evidence=NULL,*name=NULL,*type=NULL;
|
||
const char *size=NULL,*hash=NULL,*source=NULL,*description=NULL;
|
||
for(int i=4;i+1<argc;i+=2){
|
||
if(strcmp(argv[i],"--upload")==0)upload=argv[i+1];
|
||
else if(strcmp(argv[i],"--key")==0)key=argv[i+1];
|
||
else if(strcmp(argv[i],"--evidence")==0)evidence=argv[i+1];
|
||
else if(strcmp(argv[i],"--name")==0)name=argv[i+1];
|
||
else if(strcmp(argv[i],"--type")==0)type=argv[i+1];
|
||
else if(strcmp(argv[i],"--size")==0)size=argv[i+1];
|
||
else if(strcmp(argv[i],"--sha256")==0)hash=argv[i+1];
|
||
else if(strcmp(argv[i],"--source")==0)source=argv[i+1];
|
||
else if(strcmp(argv[i],"--description")==0)description=argv[i+1];}
|
||
ok=confirm_import(workspace,upload,key,evidence,name,type,size,hash,
|
||
source,description,
|
||
strcmp(argv[1],"__test-crash-import-after-commit")==0,
|
||
&error);
|
||
}
|
||
else if (strcmp(argv[1], "init-j7-specimen") == 0)
|
||
ok = init_j7_specimen(workspace, &error);
|
||
else if (strcmp(argv[1], "evidence-preview-json") == 0) {
|
||
const char *evidence = NULL;
|
||
for (int i = 4; i + 1 < argc; i += 2)
|
||
if (strcmp(argv[i], "--evidence") == 0) evidence = argv[i + 1];
|
||
ok = print_evidence_preview(workspace, evidence, &error);
|
||
} else if (strcmp(argv[1], "observations-json") == 0) {
|
||
const char *evidence = NULL, *extraction = NULL;
|
||
for (int i = 4; i + 1 < argc; i += 2) {
|
||
if (strcmp(argv[i], "--evidence") == 0) evidence = argv[i + 1];
|
||
else if (strcmp(argv[i], "--extraction") == 0) extraction = argv[i + 1];
|
||
}
|
||
ok = print_observations(workspace, evidence, extraction, &error);
|
||
} else if (strcmp(argv[1], "review-observation-json") == 0) {
|
||
const char *evidence=NULL,*observation=NULL,*operation=NULL,*revision=NULL;
|
||
const char *action=NULL,*status=NULL,*corrected=NULL,*entity=NULL;
|
||
const char *author=NULL,*reason=NULL;
|
||
for (int i=4;i+1<argc;i+=2) {
|
||
if(strcmp(argv[i],"--evidence")==0)evidence=argv[i+1];
|
||
else if(strcmp(argv[i],"--observation")==0)observation=argv[i+1];
|
||
else if(strcmp(argv[i],"--operation")==0)operation=argv[i+1];
|
||
else if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];
|
||
else if(strcmp(argv[i],"--action")==0)action=argv[i+1];
|
||
else if(strcmp(argv[i],"--status")==0)status=argv[i+1];
|
||
else if(strcmp(argv[i],"--corrected")==0)corrected=argv[i+1];
|
||
else if(strcmp(argv[i],"--entity")==0)entity=argv[i+1];
|
||
else if(strcmp(argv[i],"--author")==0)author=argv[i+1];
|
||
else if(strcmp(argv[i],"--reason")==0)reason=argv[i+1];
|
||
}
|
||
ok=review_observation(workspace,evidence,observation,operation,revision,
|
||
action,status,corrected,entity,author,reason,&error);
|
||
}
|
||
else if (strcmp(argv[1], "enqueue") == 0)
|
||
ok = enqueue_jobs(workspace, &error);
|
||
else if (strcmp(argv[1], "submit-json") == 0 && argc >= 9) {
|
||
const char *evidence = NULL, *capability = NULL, *key = NULL;
|
||
for (int i = 4; i + 1 < argc; i += 2) {
|
||
if (strcmp(argv[i], "--evidence") == 0)
|
||
evidence = argv[i + 1];
|
||
else if (strcmp(argv[i], "--capability") == 0)
|
||
capability = argv[i + 1];
|
||
else if (strcmp(argv[i], "--key") == 0)
|
||
key = argv[i + 1];
|
||
}
|
||
char *job_id = NULL;
|
||
ok = submit_job(workspace, evidence, capability, key, &job_id, &error);
|
||
if (ok)
|
||
printf("{\"contract\":\"labfy.local_jobs.command.v1\","
|
||
"\"accepted\":true,\"job_id\":\"%s\"}\n",
|
||
job_id);
|
||
g_free(job_id);
|
||
} else if (strcmp(argv[1], "submit-plan-json") == 0) {
|
||
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
|
||
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
|
||
ok=submit_plan(workspace,revision,profile,key,ids,FALSE,&error);
|
||
} else if (strcmp(argv[1], "__test-crash-after-admission") == 0) {
|
||
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
|
||
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
|
||
ok=submit_plan(workspace,revision,profile,key,ids,TRUE,&error);
|
||
} else if (strcmp(argv[1], "lookup-plan-json") == 0) {
|
||
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
|
||
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
|
||
ok=lookup_plan(workspace,revision,profile,key,ids,&error);
|
||
} else if (strcmp(argv[1], "prepare-report-json") == 0) {
|
||
const char *objects=NULL,*title=NULL,*comment=NULL,*generated=NULL,*sections=NULL;
|
||
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--objects")==0)objects=argv[i+1];else if(strcmp(argv[i],"--title")==0)title=argv[i+1];else if(strcmp(argv[i],"--comment")==0)comment=argv[i+1];else if(strcmp(argv[i],"--generated-at")==0)generated=argv[i+1];else if(strcmp(argv[i],"--sections")==0)sections=argv[i+1];}
|
||
ok=prepare_report(workspace,objects,title,comment,generated,sections,&error);
|
||
} else if (strcmp(argv[1], "run") == 0)
|
||
ok = run_worker(workspace, 0U, &error);
|
||
else if (strcmp(argv[1], "__test-crash-after-claim") == 0)
|
||
ok = run_worker(workspace, 1U, &error);
|
||
else if (strcmp(argv[1], "__test-crash") == 0)
|
||
ok = run_worker(workspace, 2U, &error);
|
||
else if (strcmp(argv[1], "status") == 0)
|
||
ok = status_or_export(workspace, TRUE, &error);
|
||
else if (strcmp(argv[1], "export") == 0)
|
||
ok = status_or_export(workspace, FALSE, &error);
|
||
else if (strcmp(argv[1], "demo") == 0)
|
||
return demonstrate(workspace, argv[0], &error);
|
||
else if (strcmp(argv[1], "cancel") == 0 && argc >= 5)
|
||
ok = control(workspace, argv[1], argv[4], &error);
|
||
else if (strcmp(argv[1], "pause") == 0 || strcmp(argv[1], "resume") == 0 ||
|
||
strcmp(argv[1], "stop") == 0)
|
||
ok = control(workspace, argv[1], NULL, &error);
|
||
if (!ok) {
|
||
fprintf(stderr, "local-jobs: %s\n",
|
||
error != NULL ? error->message : "commande invalide");
|
||
g_clear_error(&error);
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|