1#include "arachne/contracts.hpp"
2#include "arachne/coordinator.hpp"
3#include "arachne/crypto.hpp"
4#include "ariadne/candidates.hpp"
5#include "ariadne/viewer.hpp"
6#include "penelope/inbox.hpp"
7#include "penelope/store.hpp"
8#include "pheidippides/hardened_transport.hpp"
9#include "pheidippides/transport.hpp"
11#include <nlohmann/json.hpp>
38#include <system_error>
44namespace fs = std::filesystem;
55class cli_error
final :
public std::runtime_error {
57 explicit cli_error(std::string message,
const int exit_code = 2)
58 : std::runtime_error(std::move(message))
68#ifdef ARACHNE_SOURCE_DIR
69 return fs::path(ARACHNE_SOURCE_DIR);
71 return fs::current_path();
77 std::error_code error;
78 fs::path candidate = path.is_absolute() ? path : relative_root / path;
79 fs::path result = fs::weakly_canonical(candidate, error);
81 return result.lexically_normal();
84 result = fs::absolute(candidate, error);
86 throw cli_error(
"cannot resolve path: " + candidate.string());
88 return result.lexically_normal();
92 const char* home = std::getenv(
"HOME");
93 if (home ==
nullptr || *home ==
'\0') {
102 const fs::path& path,
const std::uintmax_t maximum_bytes,
103 const std::string_view description
105 std::error_code error;
106 const auto state = fs::symlink_status(path, error);
107 if (error || !fs::is_regular_file(state)) {
109 std::string(description)
110 +
" is not a regular file: " + path.string()
113 const std::uintmax_t size = fs::file_size(path, error);
116 "cannot inspect " + std::string(description) +
": "
120 if (size > maximum_bytes
121 || size >
static_cast<std::uintmax_t>(
122 std::numeric_limits<std::streamsize>::max()
124 throw cli_error(std::string(description) +
" exceeds its byte limit");
126 std::ifstream input(path, std::ios::binary);
129 "cannot open " + std::string(description) +
": " + path.string()
132 std::string result(
static_cast<std::size_t>(size),
'\0');
133 if (!result.empty()) {
134 input.read(result.data(),
static_cast<std::streamsize>(result.size()));
136 if (!input || input.peek() != std::char_traits<
char>::eof()) {
137 throw cli_error(
"cannot read complete " + std::string(description));
143 const fs::path& path,
const std::uintmax_t maximum_bytes,
144 const std::string_view description
146 const std::string bytes = read_bytes(path, maximum_bytes, description);
148 return json::parse(bytes);
149 }
catch (
const json::exception& error) {
151 std::string(description) +
" is not valid JSON: " + error.what()
157 const std::time_t now = std::time(
nullptr);
159 if (::gmtime_r(&now, &value) ==
nullptr) {
160 throw cli_error(
"cannot produce a UTC operation timestamp");
162 std::ostringstream output;
163 output << std::put_time(&value,
"%Y-%m-%dT%H:%M:%SZ");
167void emit(
const json& document) {
168 std::cout << arachnespace::contracts::canonical_json(document) <<
'\n';
173 const std::string_view description
175 std::string message = std::string(description) +
" validation failed";
176 for (
const auto& diagnostic : result.diagnostics) {
178 + (diagnostic.instance_path.empty() ? std::string(
"/")
179 : diagnostic.instance_path)
180 +
" [" + diagnostic.code +
"] " + diagnostic.message;
188 const std::vector<std::string>& arguments,
const std::size_t first,
189 const std::initializer_list<std::string_view> allowed,
190 const std::initializer_list<std::string_view> flags = {}
192 std::set<std::string, std::less<>> allowed_values;
193 std::set<std::string, std::less<>> allowed_flags;
194 for (
const auto value : allowed) {
195 allowed_values.emplace(value);
197 for (
const auto value : flags) {
198 allowed_flags.emplace(value);
200 for (std::size_t index = first; index < arguments.size();) {
201 const std::string& name = arguments[index];
202 if (!name.starts_with(
"--")) {
203 throw cli_error(
"unexpected positional argument: " + name);
205 if (values_.contains(name) || present_flags_.contains(name)) {
206 throw cli_error(
"duplicate option: " + name);
208 if (allowed_flags.contains(name)) {
209 present_flags_.emplace(name);
213 if (!allowed_values.contains(name)) {
214 throw cli_error(
"unknown option: " + name);
216 if (index + 1U >= arguments.size()
217 || arguments[index + 1U].starts_with(
"--")) {
218 throw cli_error(
"option requires a value: " + name);
220 values_.emplace(name, arguments[index + 1U]);
225 [[nodiscard]]
const std::string&
227 const auto value = values_.find(name);
228 if (value == values_.end() || value->second.empty()) {
229 throw cli_error(
"required option is missing: " + std::string(name));
231 return value->second;
234 [[nodiscard]] std::optional<std::string>
236 const auto value = values_.find(name);
237 if (value == values_.end()) {
240 return value->second;
243 [[nodiscard]]
bool flag(
const std::string_view name)
const {
244 return present_flags_.contains(name);
253 const json& object,
const std::string_view key,
254 const std::uint64_t default_value,
const bool positive
256 const auto value = object.find(key);
257 if (value == object.end()) {
258 return default_value;
260 if (!value->is_number_integer()) {
262 "configuration field " + std::string(key) +
" must be an integer"
265 std::uint64_t result = 0;
267 result = value->get<std::uint64_t>();
268 }
catch (
const json::exception&) {
270 "configuration field " + std::string(key) +
" must be non-negative"
273 if (positive && result == 0U) {
275 "configuration field " + std::string(key) +
" must be positive"
282 const json& object,
const std::string_view key,
const std::size_t fallback
284 const std::uint64_t value = unsigned_value(
285 object, key,
static_cast<std::uint64_t>(fallback),
true
287 if (value > std::numeric_limits<std::size_t>::max()) {
289 "configuration field " + std::string(key) +
" is too large"
292 return static_cast<std::size_t>(value);
295struct configuration
final {
314 const auto value = parent.find(key);
315 if (value == parent.end() || !value->is_object()) {
317 "configuration " + std::string(key) +
" must be an object"
324 configuration result;
326 result.document = read_json(
327 result.file, maximum_config_bytes,
"operations configuration"
329 if (!result.document.is_object()
330 || result.document.value(
"format_version", 0) != 1) {
331 throw cli_error(
"only operations configuration version 1 is supported");
333 if (!result.document.contains(
"project_timezone")
334 || !result.document.at(
"project_timezone").is_string()
335 || result.document.at(
"project_timezone")
336 .get_ref<
const std::string&>()
338 throw cli_error(
"project_timezone must be a non-empty string");
341 const json& paths = required_object(result.document,
"paths");
343 auto configured_path = [&](
const std::string_view key) {
344 const auto value = paths.find(key);
345 if (value == paths.end() || !value->is_string()
346 || value->get_ref<
const std::string&>().empty()) {
348 "configuration paths." + std::string(key) +
" is missing"
351 const std::string& text = value->get_ref<
const std::string&>();
352 if (text.starts_with(
"/absolute/path/to/")) {
354 "replace the placeholder paths." + std::string(key)
357 return resolved_path(fs::path(text), root);
359 result.queue = configured_path(
"queue");
360 if (
const auto legacy = paths.find(
"legacy_inbox");
361 legacy != paths.end() && !legacy->is_null()) {
362 if (!legacy->is_string()
363 || legacy->get_ref<
const std::string&>().empty()) {
365 "configuration paths.legacy_inbox must be a non-empty string"
369 fs::path(legacy->get_ref<
const std::string&>())
, root
372 result.ledger = configured_path(
"ledger");
373 result.graph_store = configured_path(
"graph_store");
374 result.artifact_store = configured_path(
"artifact_store");
375 result.lock_root = configured_path(
"lock_root");
376 result.viewer_templates = configured_path(
"viewer_templates");
377 result.site_output = configured_path(
"site_output");
378 if (!fs::is_directory(result.queue)) {
380 "configured queue is not a directory: " + result.queue.string()
383 if (
const auto conventional = conventional_legacy_inbox()) {
384 result.protected_legacy_inboxes.push_back(*conventional);
386 if (result.legacy_inbox
387 && std::ranges::find(
388 result.protected_legacy_inboxes, *result.legacy_inbox
389 ) == result.protected_legacy_inboxes.end()) {
390 result.protected_legacy_inboxes.push_back(*result.legacy_inbox);
392 for (
const auto& legacy : result.protected_legacy_inboxes) {
393 if (arachne::coordination::path_is_within(result.queue, legacy)
394 || arachne::coordination::path_is_within(legacy, result.queue)) {
396 "the mutable queue and read-only legacy inbox must be disjoint"
401 for (
const auto& [name, path] :
402 std::array<std::pair<std::string_view,
const fs::path*>, 6> {
403 { {
"ledger", &result.ledger },
404 {
"graph_store", &result.graph_store },
405 {
"artifact_store", &result.artifact_store },
406 {
"lock_root", &result.lock_root },
407 {
"viewer_templates", &result.viewer_templates },
408 {
"site_output", &result.site_output } } }) {
409 if (arachne::coordination::path_is_within(*path, result.queue)
410 || arachne::coordination::path_is_within(result.queue, *path)) {
412 "configuration paths." + std::string(name)
413 +
" and the mutable batch queue must be disjoint"
416 for (
const auto& legacy : result.protected_legacy_inboxes) {
417 if (arachne::coordination::path_is_within(*path, legacy)
418 || arachne::coordination::path_is_within(legacy, *path)) {
420 "configuration paths." + std::string(name)
421 +
" and the read-only legacy inbox must be disjoint"
427 const json& security = required_object(result.document,
"security");
428 result.submission_max_bytes = unsigned_value(
429 security,
"submission_max_bytes", 64U * 1024U * 1024U,
true
432 = required_object(result.document,
"product_integration");
433 const json& candidate
434 = required_object(result.document,
"candidate_rebuild");
435 const std::uint64_t product_stale
436 = unsigned_value(product,
"lock_stale_seconds", 21'600U,
true);
437 const std::uint64_t candidate_stale
438 = unsigned_value(candidate,
"lock_stale_seconds", 21'600U,
true);
439 const auto seconds_max =
static_cast<std::uint64_t>(
440 std::numeric_limits<std::chrono::seconds::rep>::max()
442 if (product_stale > seconds_max || candidate_stale > seconds_max) {
443 throw cli_error(
"configured lock stale interval is too large");
445 result.product_lock_stale = std::chrono::seconds(
446 static_cast<std::chrono::seconds::rep>(product_stale)
448 result.candidate_lock_stale = std::chrono::seconds(
449 static_cast<std::chrono::seconds::rep>(candidate_stale)
451 static_cast<
void>(required_object(result.document,
"publication"));
456 const configuration& config,
const std::string_view section
458 const auto value = config.document.find(section);
459 if (value == config.document.end()) {
461 "configuration section is missing: " + std::string(section)
464 const ordered_json stable {
465 {
"format_version", config.document.at(
"format_version") },
466 {
"project_timezone", config.document.at(
"project_timezone") },
467 { std::string(section), *value },
480 return std::ranges::any_of(
481 config.protected_legacy_inboxes, [&](
const fs::path& legacy) {
482 return arachne::coordination::path_is_within(path, legacy);
488 const fs::path& destination,
const std::string_view bytes,
491 if (destination.empty() || !destination.has_filename()) {
492 throw cli_error(
"output path must identify a file");
494 fs::create_directories(destination.parent_path());
495 const std::uint64_t sequence
497 const fs::path staging = destination.parent_path()
498 / (
"." + destination.filename().string() +
".tmp-"
499 + std::to_string(
static_cast<
long long>(::getpid())) +
"-"
500 + std::to_string(sequence));
503 std::ofstream output(staging, std::ios::binary | std::ios::trunc);
506 "cannot create staging output: " + staging.string()
510 bytes.data(),
static_cast<std::streamsize>(bytes.size())
515 "cannot write staging output: " + staging.string()
520 std::error_code error;
521 fs::rename(staging, destination, error);
524 "cannot publish output " + destination.string() +
": "
529 fs::create_hard_link(staging, destination);
533 std::error_code ignored;
534 fs::remove(staging, ignored);
540 const fs::path& destination,
const std::string_view bytes,
541 const std::string_view description
543 if (fs::exists(destination)) {
546 std::string(
"existing ") + std::string(description)
550 std::string(description)
551 +
" identity is already bound to different content"
556 atomic_write(destination, bytes,
false);
561 ordered_json result {
563 {
"payload_ref", envelope.payload_ref.generic_string() },
568 {
"title", envelope
.title },
569 {
"status", arachne::coordination::to_string(envelope
.status) },
571 result[
"accepted_by"]
572 = envelope.accepted_by ? json(*envelope.accepted_by) : json(
nullptr);
574 = envelope.supersedes ? json(*envelope.supersedes) : json(
nullptr);
581 {
"domain",
"research_candidate_graph" },
583 {
"database_path", snapshot.database_path.generic_string() },
584 {
"export_path", snapshot.export_path.generic_string() },
585 {
"metadata_path", snapshot.metadata_path.generic_string() },
595struct written_run_manifest
final {
602 const configuration& config,
const std::string_view domain_directory,
603 const std::string_view graph_domain,
const std::string_view run_id,
604 ordered_json configuration_hashes, ordered_json inputs,
605 const arachne::
penelope::snapshot_result& snapshot
607 const json metadata = read_json(
608 snapshot.metadata_path, maximum_control_bytes,
"snapshot metadata"
610 ordered_json outputs = ordered_json::array();
612 { {
"kind",
"graph-database" },
613 {
"artifact", metadata.at(
"database") } }
615 for (
const auto& exported : metadata.at(
"exports")) {
617 { {
"kind", exported.at(
"kind") },
618 {
"artifact", exported.at(
"artifact") } }
622 { {
"kind",
"structural-validation-report" },
623 {
"artifact", metadata.at(
"structural_validation").at(
"report") } }
625 const fs::path metadata_ref
626 = snapshot.metadata_path.lexically_relative(config.graph_store);
627 if (metadata_ref.empty()
628 || !arachne::crypto::is_safe_relative_artifact_ref(
629 metadata_ref.generic_string()
631 throw cli_error(
"snapshot metadata has no safe graph-store reference");
634 { {
"kind",
"snapshot-control" },
636 { {
"storage_ref", metadata_ref.generic_string() },
639 {
"byte_length", fs::file_size(snapshot.metadata_path) },
640 {
"media_type",
"application/json" } } } }
643 ordered_json manifest {
644 {
"manifest_type",
"arachne_run_manifest_v1" },
645 {
"format_version", 1 },
646 {
"run_id", std::string(run_id) },
647 {
"graph_domain", std::string(graph_domain) },
648 {
"generated_at", metadata.at(
"activated_at") },
650 { {
"arachne",
"2.0.0" },
651 {
"pheidippides",
"pheidippides-transport-2.0.0" },
652 {
"ariadne",
"ariadne-engine-2.0.0" },
653 {
"penelope",
"penelope-store-2.0.0" } } },
654 {
"contract_versions",
656 {
"arachne_batch_v2",
"batch_envelope_v1",
"fetch_plan_v1",
657 "fetch_request_v1",
"acquired_artifact_v1",
658 "research_candidate_graph_plan_v1",
"product_graph_snapshot_v1",
659 "research_candidate_graph_snapshot_v1",
"viewer_projection_v1",
660 "site_bundle_v1" } },
662 {
"external_candidate_source_graph_v1",
663 "research_candidate_graph_materialization_v1",
664 "viewer_projection_data_v1" } } } },
665 {
"configuration_hashes", std::move(configuration_hashes) },
666 {
"inputs", std::move(inputs) },
667 {
"outputs", std::move(outputs) },
668 {
"structural_validation", metadata.at(
"structural_validation") },
670 const fs::path path = config.graph_store / domain_directory /
"runs"
671 / (std::string(run_id) +
".json");
672 const fs::path relative = path.lexically_relative(config.graph_store);
673 if (!arachne::crypto::is_safe_relative_artifact_ref(
674 relative.generic_string()
676 throw cli_error(
"run manifest has no safe graph-store reference");
678 const std::string bytes
679 = arachnespace::contracts::canonical_json(manifest) +
"\n";
680 if (fs::exists(path)) {
684 "run manifest identity is already bound to different content"
690 return { std::move(manifest), path, relative.generic_string() };
694 return { {
"path", std::move(path) }, {
"message", std::move(message) } };
699 const fs::path relative = candidate.lexically_relative(root);
700 fs::path current = root;
701 for (
const auto& component : relative) {
702 current /= component;
703 std::error_code error;
704 const auto state = fs::symlink_status(current, error);
708 if (fs::is_symlink(state)) {
716 const configuration& config,
const fs::path& control_path,
717 const std::string_view storage_ref
719 if (!arachne::crypto::is_safe_relative_artifact_ref(storage_ref)) {
721 "plan_artifact.storage_ref is not a safe relative path"
724 std::vector<fs::path> matches;
725 for (
const fs::path& root :
726 std::array { control_path.parent_path(), config.artifact_store }) {
727 const fs::path candidate
728 = arachne::crypto::safe_artifact_path(root, storage_ref);
729 std::error_code error;
730 const auto state = fs::symlink_status(candidate, error);
731 if (error || !fs::is_regular_file(state) || fs::is_symlink(state)) {
734 if (!arachne::coordination::path_is_within(candidate, root)
735 || path_has_symlink(root, candidate)) {
737 "candidate plan artifact traverses a symbolic link"
740 const fs::path canonical = fs::canonical(candidate);
741 if (std::ranges::find(matches, canonical) == matches.end()) {
742 matches.push_back(canonical);
745 if (matches.empty()) {
747 "candidate plan artifact cannot be resolved beneath its control "
748 "directory or artifact store"
751 if (matches.size() != 1U) {
752 throw cli_error(
"candidate plan artifact reference is ambiguous");
754 return matches.front();
757struct resolved_snapshot_export
final {
763 const configuration& config,
const fs::path& control_path,
765 const std::string_view expected_kind
767 json control = read_json(
768 control_path, maximum_control_bytes,
"graph snapshot control"
770 const auto validation
771 = arachnespace::contracts::validate(expected_contract, control);
774 validation_details(validation,
"graph snapshot control")
777 const json* selected =
nullptr;
778 for (
const auto& value : control.at(
"exports")) {
779 if (value.is_object() && value.value(
"kind",
"") == expected_kind) {
780 if (selected !=
nullptr) {
782 "snapshot control contains duplicate "
783 + std::string(expected_kind) +
" exports"
786 selected = &value.at(
"artifact");
789 if (selected ==
nullptr) {
791 "snapshot control has no " + std::string(expected_kind) +
" export"
794 const std::string storage_ref
795 = selected->at(
"storage_ref").get<std::string>();
796 if (!arachne::crypto::is_safe_relative_artifact_ref(storage_ref)) {
797 throw cli_error(
"snapshot export storage_ref is unsafe");
799 std::vector<fs::path> matches;
800 for (
const fs::path& root :
801 std::array { control_path.parent_path(), config.graph_store }) {
802 const fs::path candidate
803 = arachne::crypto::safe_artifact_path(root, storage_ref);
804 std::error_code error;
805 const auto state = fs::symlink_status(candidate, error);
806 if (error || !fs::is_regular_file(state) || fs::is_symlink(state)) {
809 if (!arachne::coordination::path_is_within(candidate, root)
810 || path_has_symlink(root, candidate)) {
811 throw cli_error(
"snapshot export traverses a symbolic link");
813 const fs::path canonical = fs::canonical(candidate);
814 if (std::ranges::find(matches, canonical) == matches.end()) {
815 matches.push_back(canonical);
818 if (matches.size() != 1U) {
820 matches.empty() ?
"snapshot export cannot be resolved"
821 :
"snapshot export reference is ambiguous"
824 const fs::path export_path = matches.front();
825 if (fs::file_size(export_path)
826 != selected->at(
"byte_length").get<std::uintmax_t>()
828 != selected->at(
"sha256").get<std::string>()) {
830 "snapshot export does not match its declared hash and byte length"
833 return { std::move(control), export_path };
837 const configuration& config,
const json& external_graph
839 const auto& source = external_graph.at(
"source_snapshot");
840 const std::string storage_ref = source.at(
"storage_ref").get<std::string>();
841 if (!arachne::crypto::is_safe_relative_artifact_ref(storage_ref)) {
842 throw cli_error(
"external source snapshot storage_ref is unsafe");
844 const fs::path artifact = arachne::crypto::safe_artifact_path(
845 config.artifact_store, storage_ref
847 std::error_code error;
848 const auto state = fs::symlink_status(artifact, error);
849 if (error || !fs::is_regular_file(state) || fs::is_symlink(state)
850 || path_has_symlink(config.artifact_store, artifact)
852 != source.at(
"sha256").get<std::string>()) {
854 "external source snapshot is unavailable or does not match its hash"
860 const json& external_graph,
const json& product_tables
862 std::set<std::string, std::less<>> product_works;
863 for (
const auto& work : product_tables.value(
"works", json::array())) {
864 if (work.is_object() && work.contains(
"entity_id")
865 && work.at(
"entity_id").is_string()) {
866 product_works.insert(work.at(
"entity_id").get<std::string>());
869 std::set<std::string, std::less<>> covered_external_ids;
870 for (
const auto& identifier :
871 product_tables.value(
"external_ids", json::array())) {
872 if (identifier.is_object()
873 && identifier.value(
"scheme",
"") ==
"wikidata"
874 && product_works.contains(identifier.value(
"entity_id",
""))
875 && identifier.contains(
"value")
876 && identifier.at(
"value").is_string()) {
877 covered_external_ids.insert(
878 identifier.at(
"value").get<std::string>()
882 for (
const auto& work : external_graph.at(
"works")) {
883 const std::string id = work.at(
"id").get<std::string>();
884 const bool expected = covered_external_ids.contains(id);
885 if (!work.at(
"covered").is_boolean()
886 || work.at(
"covered").get<
bool>() != expected) {
888 "external graph coverage disagrees with the verified product "
897 const json& row,
const std::string_view key, json fallback
899 const auto value = row.find(key);
900 if (value == row.end() || value->is_null()) {
903 if (!value->is_string()) {
907 return json::parse(value->get_ref<
const std::string&>());
908 }
catch (
const json::exception& error) {
910 "invalid embedded JSON in export field " + std::string(key) +
": "
918 {
"artifact_type",
"research_candidate_graph_materialization_v1" },
919 {
"format_version", 1 },
920 {
"algorithm", { {
"version",
"candidate-materialization-v1" } } },
921 {
"groups", json::array() },
922 {
"candidates", json::array() },
923 {
"works", json::array() },
924 {
"relations", json::array() },
926 if (tables.contains(
"candidate_graph_info")
927 && tables.at(
"candidate_graph_info").is_array()
928 && !tables.at(
"candidate_graph_info").empty()) {
929 const auto& info = tables.at(
"candidate_graph_info").front();
930 result[
"algorithm"][
"version"]
931 = info.value(
"algorithm_version",
"candidate-materialization-v1");
933 for (
const auto& group : tables.value(
"candidate_groups", json::array())) {
935 = parse_embedded_json(group,
"metadata_json", json::object());
936 materialized[
"group_id"] = group.value(
"id",
"");
937 materialized[
"label"] = group.value(
"label",
"");
938 materialized[
"order"] = group.value(
"ordinal", 0);
939 result[
"groups"].push_back(std::move(materialized));
941 for (
const auto& node : tables.value(
"candidate_nodes", json::array())) {
943 = parse_embedded_json(node,
"source_metadata_json", json::object());
945 = parse_embedded_json(node,
"selection_reason_json", json::array());
946 const std::string kind = node.value(
"entity_type",
"candidate");
947 if (kind ==
"candidate_work") {
948 json attributes { {
"noncanonical",
true },
949 {
"soft_guidance",
true } };
950 if (source.contains(
"year")) {
951 attributes[
"year"] = source.at(
"year");
953 result[
"works"].push_back(
954 { {
"work_id", node.value(
"id",
"") },
955 {
"candidate_id", source.value(
"candidate_id",
"") },
956 {
"external_id", node.value(
"entity_ref",
"") },
957 {
"label", node.value(
"label",
"") },
958 {
"attributes", std::move(attributes) } }
962 result[
"candidates"].push_back(
963 { {
"candidate_id", node.value(
"id",
"") },
964 {
"external_id", node.value(
"entity_ref",
"") },
965 {
"label", node.value(
"label",
"") },
967 {
"rank", node.value(
"rank", 0) },
968 {
"coverage", node.value(
"coverage", 0.0) },
969 {
"group_id", node.value(
"group_id",
"unassigned") },
970 {
"selection_reasons", reasons },
972 { {
"noncanonical",
true },
973 {
"is_grey", node.value(
"is_grey", 0) != 0 },
974 {
"source", source } } } }
977 for (
const auto& edge : tables.value(
"candidate_edges", json::array())) {
978 result[
"relations"].push_back(
979 { {
"relation_id", edge.value(
"id",
"") },
980 {
"source_id", edge.value(
"subject_id",
"") },
981 {
"target_id", edge.value(
"object_id",
"") },
982 {
"relation_type", edge.value(
"relation_type",
"") },
983 {
"weight", edge.value(
"weight", 0.0) },
985 parse_embedded_json(edge,
"metadata_json", json::object()) },
986 {
"attributes", { {
"soft_guidance",
true } } } }
994 const std::string bytes
995 = read_bytes(path, maximum_export_bytes,
"graph export");
996 const json whole = json::parse(bytes,
nullptr,
false);
997 if (!whole.is_discarded()) {
998 if (whole.is_object() && !whole.contains(
"table")) {
999 if (candidate && whole.contains(
"candidate_nodes")) {
1000 return candidate_tables_for_viewer(whole);
1004 if (!whole.is_array() && !whole.is_object()) {
1006 "graph export JSON must be an object or record array"
1011 json tables = json::object();
1012 auto add_record = [&](
const json& record,
const std::size_t line_number) {
1013 if (!record.is_object() || !record.contains(
"table")
1014 || !record.at(
"table").is_string() || !record.contains(
"row")
1015 || !record.at(
"row").is_object()) {
1017 "invalid Penelope JSONL record at line "
1018 + std::to_string(line_number)
1021 const std::string table = record.at(
"table").get<std::string>();
1022 if (!tables.contains(table)) {
1023 tables[table] = json::array();
1025 tables[table].push_back(record.at(
"row"));
1027 if (whole.is_array()) {
1028 std::size_t index = 0;
1029 for (
const auto& record : whole) {
1030 add_record(record, ++index);
1032 }
else if (whole.is_object()) {
1033 add_record(whole, 1U);
1035 std::istringstream lines(bytes);
1037 std::size_t line_number = 0;
1038 while (std::getline(lines, line)) {
1044 add_record(json::parse(line), line_number);
1045 }
catch (
const json::exception& error) {
1047 "invalid graph JSONL at line " + std::to_string(line_number)
1048 +
": " + error.what()
1053 return candidate ? candidate_tables_for_viewer(tables) : tables;
1057 if (value.size() != 10U || value[4] !=
'-' || value[7] !=
'-') {
1063 const auto year_result
1064 = std::from_chars(value.data(), value.data() + 4, year);
1065 const auto month_result
1066 = std::from_chars(value.data() + 5, value.data() + 7, month);
1067 const auto day_result
1068 = std::from_chars(value.data() + 8, value.data() + 10, day);
1069 if (year_result.ec != std::errc {} || month_result.ec != std::errc {}
1070 || day_result.ec != std::errc {}) {
1073 return std::chrono::year_month_day(
1074 std::chrono::year(year), std::chrono::month(month),
1075 std::chrono::day(day)
1082 const json& candidate
1083 = required_object(config.document,
"candidate_rebuild");
1084 const json& sources = required_object(candidate,
"sources");
1085 if (sources.empty()) {
1086 throw cli_error(
"candidate_rebuild.sources must not be empty");
1088 const json& source = sources.begin().value();
1089 if (!source.is_object()) {
1090 throw cli_error(
"candidate source configuration must be an object");
1093 result.pool_size = size_value(source,
"candidate_pool_size", 3000U);
1094 result.target_size = size_value(source,
"final_target", 1500U);
1095 result.group_count = size_value(source,
"group_count", 4U);
1097 const auto gray = source.find(
"gray_bonus_basis_points");
1098 if (gray != source.end()) {
1099 if (!gray->is_number_integer()) {
1100 throw cli_error(
"gray_bonus_basis_points must be an integer");
1104 const auto quality = source.find(
"quality_weight");
1105 if (quality != source.end()) {
1106 if (!quality->is_number()) {
1107 throw cli_error(
"quality_weight must be numeric");
1112 arachne::ariadne::candidate_planner::configuration_values(result)
1118 static_cast<
void>(load_configuration(arguments.require(
"--config")));
1119 const std::string& wire_name = arguments.require(
"--contract");
1121 = arachnespace::contracts::parse_contract_name(wire_name);
1123 throw cli_error(
"unsupported contract name: " + wire_name);
1125 const fs::path input = command_path(arguments.require(
"--input"));
1127 = read_json(input, maximum_control_bytes,
"contract input");
1128 const auto validation
1129 = arachnespace::contracts::validate(*expected, document);
1130 ordered_json diagnostics = ordered_json::array();
1131 for (
const auto& diagnostic : validation.diagnostics) {
1132 diagnostics.push_back(
1133 { {
"instance_path", diagnostic.instance_path },
1134 {
"code", diagnostic.code },
1135 {
"message", diagnostic.message } }
1140 {
"command",
"contract-validate" },
1141 {
"contract", wire_name },
1142 {
"input", input.generic_string() },
1143 {
"valid", validation.valid() },
1144 {
"diagnostics", std::move(diagnostics) },
1147 if (!validation.valid()) {
1148 std::cerr << validation_details(validation, wire_name) <<
'\n';
1155 const configuration config
1156 = load_configuration(arguments.require(
"--config"));
1158 config.ledger, config.legacy_inbox
1160 const fs::path payload = command_path(arguments.require(
"--payload"));
1161 const auto state = fs::symlink_status(payload);
1162 if (fs::is_symlink(state) || !fs::is_regular_file(state)) {
1163 throw cli_error(
"intake payload must be a non-symlink regular file");
1166 { .source_path = payload,
1167 .inbox_root = config.queue,
1168 .submission_ref = arguments.require(
"--submission-ref"),
1169 .title = arguments.require(
"--title"),
1170 .supersedes = arguments.optional(
"--supersedes"),
1173 emit(ordered_json { {
"status",
"ok" } });
1178 const configuration config
1179 = load_configuration(arguments.require(
"--config"));
1181 config.ledger, config.legacy_inbox
1183 const cocoon_status next = arachne::coordination::cocoon_status_from_string(
1184 arguments.require(
"--to")
1187 = arguments.optional(
"--reason")
1188 .value_or(
"transition requested through the operations CLI");
1189 if (reason.empty()) {
1190 reason =
"transition requested through the operations CLI";
1192 const auto envelope = ledger.transition(
1193 arguments.require(
"--envelope-id"), next,
1194 arguments.require(
"--actor-ref"), reason
1198 {
"command",
"cocoon-transition" },
1199 {
"envelope", envelope_json(envelope) },
1207 const fs::path& inbox
1209 ordered_json issues = ordered_json::array();
1210 for (
const auto& issue : ledger.verify_inbox(inbox)) {
1212 issue_json(issue.path.generic_string(), issue.message)
1219 const configuration config
1220 = load_configuration(arguments.require(
"--config"));
1221 if (!config.legacy_inbox || !fs::is_directory(*config.legacy_inbox)) {
1223 "an existing paths.legacy_inbox directory is required for legacy "
1224 "baseline operations"
1228 config.ledger, config.legacy_inbox
1230 ordered_json issues = actor_inbox_issues(ledger, *config.legacy_inbox);
1231 if (!issues.empty()) {
1234 {
"command",
"inbox-baseline" },
1236 {
"issues", issues },
1239 std::cerr <<
"refusing to replace an inbox baseline after mutation\n";
1243 issues = actor_inbox_issues(ledger, *config.legacy_inbox);
1244 if (!issues.empty()) {
1247 {
"command",
"inbox-baseline" },
1249 {
"issues", issues },
1252 std::cerr <<
"inbox changed while its baseline was being captured\n";
1257 {
"command",
"inbox-baseline" },
1259 {
"legacy_inbox", config.legacy_inbox->generic_string() },
1266 const configuration config
1267 = load_configuration(arguments.require(
"--config"));
1268 if (!config.legacy_inbox || !fs::is_directory(*config.legacy_inbox)) {
1270 "an existing paths.legacy_inbox directory is required for legacy "
1271 "verification operations"
1275 config.ledger, config.legacy_inbox
1277 ordered_json issues = actor_inbox_issues(ledger, *config.legacy_inbox);
1278 const bool ok = issues.empty();
1281 {
"command",
"inbox-verify" },
1283 {
"legacy_inbox", config.legacy_inbox->generic_string() },
1284 {
"issues", issues },
1288 for (
const auto& issue : issues) {
1289 std::cerr << issue.at(
"path").get<std::string>() <<
": "
1290 << issue.at(
"message").get<std::string>() <<
'\n';
1298 const auto result = apply
1301 ordered_json batches = ordered_json::array();
1302 for (
const auto& batch : result.batches) {
1303 ordered_json issues = ordered_json::array();
1304 for (
const auto& issue : batch.issues) {
1306 {
"code", issue.code },
1307 {
"json_path", issue.json_path },
1308 {
"message", issue.message },
1310 if (!issue.value_json.empty()) {
1311 item[
"value"] = json::parse(issue.value_json);
1313 issues.push_back(std::move(item));
1318 batch.path.lexically_relative(repository_root())
1319 .generic_string() },
1320 {
"batch_id", batch.batch_id },
1321 {
"status", arachne::penelope::to_string(batch.status) },
1322 {
"issues", std::move(issues) },
1328 {
"status", result.ok ?
"ok" :
"fail" },
1330 apply ?
"product-apply-inbox" :
"product-check-inbox" },
1331 {
"valid_count", result.valid_count },
1332 {
"applied_count", result.applied_count },
1333 {
"already_applied_count", result.already_applied_count },
1334 {
"rejected_count", result.rejected_count },
1335 {
"batches", std::move(batches) },
1338 return result
.ok ? 0 : 3;
1342 const std::size_t count
1347 {
"command",
"product-rebuild-merge-hints" },
1348 {
"candidate_count", count },
1355 const configuration config
1356 = load_configuration(arguments.require(
"--config"));
1357 const fs::path control_path
1358 = command_path(arguments.require(
"--plan-control"));
1359 const json control = read_json(
1360 control_path, maximum_control_bytes,
"candidate plan control"
1362 const auto validation = arachnespace::contracts::validate(
1368 validation_details(validation,
"candidate plan control")
1371 const fs::path payload = resolve_plan_artifact(
1372 config, control_path,
1373 control.at(
"plan_artifact").at(
"storage_ref").get<std::string>()
1375 verify_external_source_snapshot(
1376 config, json { {
"source_snapshot", control.at(
"source_snapshot") } }
1378 const std::string product_snapshot_id
1379 = control.at(
"product_snapshot").at(
"snapshot_id").get<std::string>();
1380 const fs::path product_control_path = config.graph_store /
"product"
1381 /
"snapshots" / product_snapshot_id /
"metadata.json";
1382 const resolved_snapshot_export product_snapshot = resolve_snapshot_export(
1383 config, product_control_path,
1384 arachnespace::contracts::contract_name::product_graph_snapshot,
1387 if (product_snapshot.control.at(
"snapshot_id") != product_snapshot_id
1388 || product_snapshot.control.at(
"content_sha256")
1389 != control.at(
"product_snapshot").at(
"sha256")) {
1391 "candidate plan product input does not match the verified product "
1395 const std::string& run_id = arguments.require(
"--run-id");
1396 const std::string created_at = control.at(
"created_at").get<std::string>();
1397 if (created_at.size() < 10U
1398 || !valid_logical_date(std::string_view(created_at).substr(0, 10U))) {
1399 throw cli_error(
"candidate plan created_at has no valid logical date");
1401 const std::string logical_date = created_at.substr(0, 10U);
1403 config.lock_root,
"research_candidate_graph", run_id,
1404 config.candidate_lock_stale
1407 config.ledger, config.legacy_inbox
1409 const std::string operations_hash
1410 = policy_configuration_hash(config,
"candidate_rebuild");
1411 const std::string plan_control_hash
1413 const std::string run_claim_hash = arachne::crypto::sha256(
1414 arachnespace::contracts::canonical_json(
1415 ordered_json { {
"operations", operations_hash },
1416 {
"plan_control", plan_control_hash } }
1419 if (!ledger.claim_logical_run(
1420 run_id,
"research_candidate_graph", logical_date, run_claim_hash,
1425 {
"command",
"candidate-rebuild" },
1426 {
"run_id", run_id },
1427 {
"processed",
false },
1428 {
"reason",
"run_already_succeeded" },
1433 std::optional<written_run_manifest> run_manifest;
1434 bool completed =
false;
1436 arachne::
penelope::store persistence(config.graph_store);
1439 .plan = { .control_contract_path = control_path,
1440 .resolved_plan_payload_path = payload } }
1442 ordered_json inputs = ordered_json::array();
1444 { {
"kind",
"candidate-plan-control" },
1445 {
"identity", control.at(
"plan_id") },
1446 {
"sha256", plan_control_hash },
1447 {
"byte_length", fs::file_size(control_path) } }
1450 { {
"kind",
"candidate-plan-artifact" },
1451 {
"identity", control.at(
"plan_id") },
1452 {
"storage_ref", control.at(
"plan_artifact").at(
"storage_ref") },
1453 {
"sha256", control.at(
"plan_artifact").at(
"sha256") },
1454 {
"byte_length", control.at(
"plan_artifact").at(
"byte_length") } }
1457 { {
"kind",
"external-source-snapshot" },
1458 {
"identity", control.at(
"source_snapshot").at(
"snapshot_id") },
1460 control.at(
"source_snapshot").at(
"storage_ref") },
1461 {
"sha256", control.at(
"source_snapshot").at(
"sha256") } }
1464 { {
"kind",
"product-snapshot" },
1465 {
"identity", product_snapshot_id },
1466 {
"sha256", control.at(
"product_snapshot").at(
"sha256") } }
1468 run_manifest = write_graph_run_manifest(
1469 config,
"candidate",
"research_candidate_graph", run_id,
1470 { {
"operations", operations_hash },
1471 {
"algorithm", control.at(
"configuration").at(
"sha256") } },
1472 std::move(inputs), snapshot
1474 ledger.finish_run(run_id,
"succeeded", run_manifest->storage_ref);
1478 {
"command",
"candidate-rebuild" },
1479 {
"run_id", run_id },
1480 {
"plan_id", control.at(
"plan_id") },
1481 {
"snapshot", snapshot_json(snapshot) },
1482 {
"run_manifest_path", run_manifest->path.generic_string() },
1483 {
"run_manifest", run_manifest->document },
1488 const std::exception_ptr failure = std::current_exception();
1490 std::rethrow_exception(failure);
1495 run_manifest ? std::string_view(run_manifest->storage_ref)
1496 : std::string_view {}
1498 }
catch (
const std::exception& error) {
1499 std::cerr <<
"warning: cannot finish failed candidate run "
1500 << run_id <<
": " << error.what() <<
'\n';
1502 std::rethrow_exception(failure);
1507 const configuration config
1508 = load_configuration(arguments.require(
"--config"));
1509 const fs::path request_path = command_path(arguments.require(
"--request"));
1510 const fs::path output_control
1511 = command_path(arguments.require(
"--output-control"));
1512 if (arachne::
coordination::path_is_within(output_control, config.queue)
1514 throw cli_error(
"acquired artifact control must be outside the inbox");
1519 = read_json(request_path, maximum_control_bytes,
"fetch request");
1521 config.artifact_store, required_object(config.document,
"transport")
1524 }
catch (
const std::exception& error) {
1525 const auto now = std::chrono::system_clock::now();
1526 acquired
.artifact_id =
"artifact-invalid-fetch-request";
1530 acquired
.source_url =
"urn:arachne:invalid-fetch-request";
1531 acquired.started_at = now;
1532 acquired.completed_at = now;
1535 const ordered_json control = arachne::pheidippides::to_contract(acquired);
1537 output_control, arachnespace::contracts::canonical_json(control) +
"\n",
1542 std::cerr <<
"transport failed: " << acquired
.error_message <<
'\n';
1549 constexpr std::string_view hexadecimal =
"0123456789ABCDEF";
1551 result.reserve(value.size());
1552 for (
const char raw_character : value) {
1553 const auto character =
static_cast<
unsigned char>(raw_character);
1554 if (std::isalnum(character) != 0 || character ==
'-' || character ==
'_'
1555 || character ==
'.' || character ==
'~') {
1556 result.push_back(
static_cast<
char>(character));
1558 result.push_back(
'%');
1559 result.push_back(hexadecimal.at(character >> 4U));
1560 result.push_back(hexadecimal.at(character & 0x0fU));
1567 return value.size() >= 2U
1568 && (value.front() ==
'Q' || value.front() ==
'P' || value.front() ==
'L'
1569 || value.front() ==
'M')
1570 && std::ranges::all_of(value.substr(1U), [](
const char character) {
1571 return character >=
'0' && character <=
'9';
1576 const std::span<
const std::string> values,
const std::string_view separator
1579 for (std::size_t index = 0; index < values.size(); ++index) {
1581 result += separator;
1583 result += values[index];
1589 const configuration& config,
const json& plan,
const json& planned,
1590 const std::span<
const std::string> entities,
const std::string& request_id
1592 static const std::map<std::string, std::string, std::less<>> field_props {
1593 {
"labels",
"labels" }, {
"descriptions",
"descriptions" },
1594 {
"aliases",
"aliases" }, {
"sitelinks",
"sitelinks" },
1595 {
"claims",
"claims" }, {
"gender",
"claims" },
1596 {
"country",
"claims" }, {
"field",
"claims" },
1597 {
"occupation",
"claims" }, {
"movement",
"claims" },
1598 {
"genre",
"claims" }, {
"language",
"claims" },
1599 {
"activity_dates",
"claims" }, {
"dates",
"claims" },
1601 if (!planned.contains(
"fields") || !planned.at(
"fields").is_array()
1602 || planned.at(
"fields").empty()) {
1604 "Wikidata entity fetches require a non-empty fields selector"
1607 std::set<std::string, std::less<>> props;
1608 for (
const auto& field : planned.at(
"fields")) {
1609 if (!field.is_string()) {
1610 throw cli_error(
"Wikidata fetch field must be a string");
1613 = field_props.find(field.get_ref<
const std::string&>());
1614 if (found == field_props.end()) {
1616 "unsupported Wikidata fetch field: " + field.get<std::string>()
1619 props.insert(found->second);
1622 if (props.contains(
"claims")) {
1623 props.insert(
"labels");
1624 props.insert(
"descriptions");
1626 const std::vector<std::string> ordered_props(props.begin(), props.end());
1627 const std::string body =
"action=wbgetentities&format=json&ids="
1628 + form_encode(join_strings(entities,
"|"))
1629 +
"&props=" + form_encode(join_strings(ordered_props,
"|"))
1630 +
"&languages=en&languagefallback=1";
1631 const fs::path body_path = config.artifact_store /
"fetch-bodies"
1632 / plan.at(
"plan_id").get<std::string>() / (request_id +
".form");
1633 const fs::path body_relative
1634 = body_path.lexically_relative(config.artifact_store);
1635 if (!arachne::crypto::is_safe_relative_artifact_ref(
1636 body_relative.generic_string()
1639 "generated fetch body has an unsafe artifact reference"
1642 write_immutable_exact(body_path, body,
"fetch request body artifact");
1644 ordered_json document {
1645 {
"contract",
"fetch_request_v1" },
1646 {
"format_version", 1 },
1647 {
"request_id", request_id },
1648 {
"door_id",
"wikidata" },
1649 {
"endpoint_id",
"entity-api" },
1650 {
"operation",
"point_lookup" },
1651 {
"freshness_policy",
"fresh_required" },
1652 {
"plan_id", plan.at(
"plan_id") },
1653 {
"locator", planned.at(
"locator") },
1654 {
"method",
"POST" },
1656 { {
"Accept",
"application/json" },
1657 {
"Content-Type",
"application/x-www-form-urlencoded" },
1659 "Arachne/2.0 (+https://github.com/ninjaro/arachne)" } } },
1660 {
"pagination", { {
"mode",
"none" } } },
1662 { {
"maximum_attempts", 3 },
1663 {
"initial_delay_ms", 250 },
1664 {
"maximum_delay_ms", 10000 },
1665 {
"total_delay_budget_ms", 30000 },
1666 {
"respect_retry_after",
true } } },
1668 { {
"maximum_bytes", 16777216 },
1669 {
"timeout_ms", 60000 },
1670 {
"connect_timeout_ms", 10000 },
1671 {
"read_timeout_ms", 30000 },
1672 {
"write_timeout_ms", 30000 } } },
1673 {
"redirect_policy",
1674 { {
"follow",
false },
1675 {
"maximum_redirects", 0 },
1676 {
"allow_https_to_http",
false },
1677 {
"allowed_hosts", {
"www.wikidata.org" } } } },
1679 "acquired/" + plan.at(
"plan_id").get<std::string>() +
"/" + request_id
1682 { {
"storage_ref", body_relative.generic_string() },
1684 {
"byte_length", body.size() },
1685 {
"media_type",
"application/x-www-form-urlencoded" } } },
1687 const auto validation = arachnespace::contracts::validate(
1692 validation_details(validation,
"translated fetch request")
1699 const json& plan,
const json& planned, std::string request_id,
1702 constexpr std::string_view dump_base
1703 =
"https://dumps.wikimedia.org/wikidatawiki/entities/";
1704 if (!locator.starts_with(dump_base)) {
1706 "Wikidata bulk fetch locator must use the official dump endpoint"
1709 const std::size_t locator_end = locator.find_first_of(
"?#");
1710 if (locator_end != std::string::npos) {
1712 "Wikidata bulk fetch locator cannot contain a query or fragment"
1715 const std::string_view locator_path(locator);
1716 std::string compression_suffix;
1717 for (
const std::string_view supported : {
1718 std::string_view {
".json.bz2" },
1719 std::string_view {
".json.gz" },
1720 std::string_view {
".json" },
1722 if (locator_path.ends_with(supported)) {
1723 compression_suffix = supported;
1727 if (compression_suffix.empty()) {
1729 "Wikidata bulk fetch locator has an unsupported dump encoding"
1732 const std::string output_ref =
"bulk/"
1733 + plan.at(
"plan_id").get<std::string>() +
"/" + request_id
1734 + compression_suffix;
1735 ordered_json document {
1736 {
"contract",
"fetch_request_v1" },
1737 {
"format_version", 1 },
1738 {
"request_id", request_id },
1739 {
"door_id",
"wikidata" },
1740 {
"endpoint_id",
"official-dumps" },
1741 {
"operation",
"bulk_snapshot" },
1742 {
"freshness_policy",
"fresh_required" },
1743 {
"plan_id", plan.at(
"plan_id") },
1744 {
"locator", std::move(locator) },
1745 {
"method",
"GET" },
1747 { {
"Accept",
"application/octet-stream" },
1749 "Arachne/2.0 (+https://github.com/ninjaro/arachne)" } } },
1750 {
"pagination", { {
"mode",
"none" } } },
1752 { {
"maximum_attempts", 5 },
1753 {
"initial_delay_ms", 1000 },
1754 {
"maximum_delay_ms", 60000 },
1755 {
"total_delay_budget_ms", 300000 },
1756 {
"respect_retry_after",
true } } },
1758 { {
"maximum_bytes", 1099511627776ULL },
1759 {
"timeout_ms", 86400000 },
1760 {
"connect_timeout_ms", 30000 },
1761 {
"read_timeout_ms", 900000 },
1762 {
"write_timeout_ms", 30000 } } },
1763 {
"redirect_policy",
1764 { {
"follow",
false },
1765 {
"maximum_redirects", 0 },
1766 {
"allow_https_to_http",
false },
1767 {
"allowed_hosts", {
"dumps.wikimedia.org" } } } },
1768 {
"output_ref", output_ref },
1770 static_cast<
void>(planned);
1771 const auto validation = arachnespace::contracts::validate(
1776 validation_details(validation,
"translated bulk request")
1783 const configuration config
1784 = load_configuration(arguments.require(
"--config"));
1785 const fs::path plan_path = command_path(arguments.require(
"--plan"));
1786 const fs::path output_directory
1787 = command_path(arguments.require(
"--output-directory"));
1788 if (arachne::
coordination::path_is_within(output_directory, config.queue)
1790 throw cli_error(
"translated fetch controls must be outside the inbox");
1792 const json plan = read_json(plan_path, maximum_control_bytes,
"fetch plan");
1793 const auto validation = arachnespace::contracts::validate(
1797 throw cli_error(validation_details(validation,
"fetch plan"));
1799 if (plan.at(
"source") !=
"wikidata") {
1801 "no closed fetch-plan adapter is registered for source "
1802 + plan.at(
"source").get<std::string>()
1806 ordered_json generated = ordered_json::array();
1807 std::set<std::string, std::less<>> concrete_ids;
1808 for (
const auto& planned : plan.at(
"requests")) {
1809 const bool has_entities = planned.contains(
"entities");
1810 const bool has_fields = planned.contains(
"fields");
1811 const bool has_pages = planned.contains(
"pages");
1812 const bool has_archives = planned.contains(
"archives");
1815 "Wikidata page selectors are not supported by this adapter"
1818 if (has_entities || has_fields) {
1819 if (!has_entities || !planned.at(
"entities").is_array()
1820 || planned.at(
"entities").empty() || !has_fields
1823 "Wikidata point selectors require non-empty entities and "
1827 if (planned.at(
"locator") !=
"https://www.wikidata.org/w/api.php") {
1829 "Wikidata entity selector uses an unsupported locator"
1832 std::vector<std::string> entities;
1833 std::set<std::string, std::less<>> unique;
1834 for (
const auto& entity : planned.at(
"entities")) {
1835 if (!entity.is_string()
1836 || !wikidata_entity_id(
1837 entity.get_ref<
const std::string&>()
1840 "Wikidata entity selector contains an invalid ID"
1843 if (!unique.emplace(entity.get<std::string>()).second) {
1845 "Wikidata entity selector contains a duplicate ID"
1848 entities.push_back(entity.get<std::string>());
1850 constexpr std::size_t maximum_entities_per_request = 50U;
1851 const std::size_t part_count
1852 = (entities.size() - 1U) / maximum_entities_per_request + 1U;
1853 for (std::size_t part = 0; part < part_count; ++part) {
1854 const std::size_t begin = part * maximum_entities_per_request;
1855 const std::size_t count = std::min(
1856 maximum_entities_per_request, entities.size() - begin
1858 std::string request_id
1859 = planned.at(
"request_id").get<std::string>();
1860 if (part_count != 1U) {
1861 request_id +=
"-part-" + std::to_string(part + 1U);
1863 if (!concrete_ids.emplace(request_id).second) {
1865 "fetch plan produces a duplicate request identity"
1868 generated.push_back(wikidata_point_fetch_request(
1869 config, plan, planned,
1870 std::span<
const std::string>(entities).subspan(
1879 if (!planned.at(
"archives").is_array()
1880 || planned.at(
"archives").empty()) {
1881 throw cli_error(
"Wikidata archives selector must not be empty");
1883 const std::string base = planned.at(
"locator").get<std::string>();
1884 if (!base.ends_with(
'/')) {
1886 "Wikidata archive locator must end with a slash"
1889 std::size_t part = 0;
1890 for (
const auto& archive : planned.at(
"archives")) {
1891 if (!archive.is_string()
1892 || !arachne::crypto::is_safe_relative_artifact_ref(
1893 archive.get_ref<
const std::string&>()
1895 || archive.get_ref<
const std::string&>().find(
'/')
1896 != std::string::npos) {
1898 "Wikidata archive selector is not a safe filename"
1901 std::string request_id
1902 = planned.at(
"request_id").get<std::string>() +
"-archive-"
1903 + std::to_string(++part);
1904 if (!concrete_ids.emplace(request_id).second) {
1906 "fetch plan produces a duplicate request identity"
1909 generated.push_back(wikidata_bulk_fetch_request(
1910 plan, planned, std::move(request_id),
1911 base + archive.get<std::string>()
1916 std::string request_id = planned.at(
"request_id").get<std::string>();
1917 if (!concrete_ids.emplace(request_id).second) {
1918 throw cli_error(
"fetch plan contains a duplicate request identity");
1920 generated.push_back(wikidata_bulk_fetch_request(
1921 plan, planned, std::move(request_id),
1922 planned.at(
"locator").get<std::string>()
1926 ordered_json controls = ordered_json::array();
1927 for (
const auto& request : generated) {
1928 const fs::path path = output_directory
1929 / (request.at(
"request_id").get<std::string>() +
".json");
1930 const std::string bytes
1931 = arachnespace::contracts::canonical_json(request) +
"\n";
1932 write_immutable_exact(path, bytes,
"translated fetch request");
1934 { {
"request_id", request.at(
"request_id") },
1935 {
"path", path.generic_string() },
1936 {
"sha256", arachne::crypto::sha256(bytes) },
1937 {
"request", request } }
1942 {
"command",
"fetch-plan-translate" },
1943 {
"plan_id", plan.at(
"plan_id") },
1944 {
"request_count", controls.size() },
1945 {
"controls", std::move(controls) },
1952 const configuration config
1953 = load_configuration(arguments.require(
"--config"));
1954 const fs::path external_graph_path
1955 = command_path(arguments.require(
"--external-graph"));
1956 const fs::path product_control_path
1957 = command_path(arguments.require(
"--product-snapshot"));
1958 const fs::path output_artifact
1959 = command_path(arguments.require(
"--output-artifact"));
1960 const fs::path output_control
1961 = command_path(arguments.require(
"--output-control"));
1963 output_artifact, config.artifact_store
1966 "candidate output artifact must be beneath the configured artifact "
1970 if (arachne::
coordination::path_is_within(output_control, config.queue)
1972 throw cli_error(
"candidate plan control must be outside the inbox");
1974 const json external_graph = read_json(
1975 external_graph_path, maximum_export_bytes,
1976 "external candidate source graph"
1978 if (!external_graph.is_object()
1979 || external_graph.value(
"artifact_type",
"")
1980 !=
"external_candidate_source_graph_v1"
1981 || external_graph.value(
"format_version", 0) != 1) {
1983 "external graph must be external_candidate_source_graph_v1"
1986 const resolved_snapshot_export product_snapshot = resolve_snapshot_export(
1987 config, product_control_path,
1988 arachnespace::contracts::contract_name::product_graph_snapshot,
1991 const json product_tables
1992 = materialize_jsonl_export(product_snapshot.export_path,
false);
1993 verify_external_source_snapshot(config, external_graph);
1994 verify_product_coverage(external_graph, product_tables);
1996 const ordered_json materialization
1997 = arachne::ariadne::candidate_planner::build(
1998 external_graph, candidate_config
2000 const fs::path storage_path
2001 = output_artifact.lexically_relative(config.artifact_store);
2002 if (storage_path.empty()
2003 || !arachne::crypto::is_safe_relative_artifact_ref(
2004 storage_path.generic_string()
2007 "candidate output artifact has no safe artifact-store reference"
2010 const ordered_json control
2011 = arachne::ariadne::candidate_planner::write_plan(
2012 materialization, output_artifact, storage_path.generic_string(),
2013 product_snapshot.control.at(
"snapshot_id").get<std::string>(),
2014 product_snapshot.control.at(
"content_sha256").get<std::string>(),
2018 output_control, arachnespace::contracts::canonical_json(control) +
"\n",
2026 const configuration config
2027 = load_configuration(arguments.require(
"--config"));
2028 const auto candidate_control = arguments.optional(
"--candidate-snapshot");
2029 if (!fs::is_directory(config.viewer_templates)) {
2030 throw cli_error(
"configured viewer template directory does not exist");
2032 const resolved_snapshot_export product_snapshot = resolve_snapshot_export(
2033 config, command_path(arguments.require(
"--product-snapshot")),
2034 arachnespace::contracts::contract_name::product_graph_snapshot,
2038 = materialize_jsonl_export(product_snapshot.export_path,
false);
2039 json candidate = json::object();
2040 std::string candidate_id =
"none";
2041 if (candidate_control) {
2042 const resolved_snapshot_export candidate_snapshot
2043 = resolve_snapshot_export(
2044 config, command_path(*candidate_control),
2045 arachnespace::contracts::contract_name::
2046 research_candidate_graph_snapshot,
2050 = materialize_jsonl_export(candidate_snapshot.export_path,
true);
2052 = candidate_snapshot.control.at(
"snapshot_id").get<std::string>();
2054 const ordered_json projection = arachne::ariadne::viewer_builder::project(
2056 product_snapshot.control.at(
"snapshot_id").get<std::string>(),
2059 const ordered_json catalog = arachne::ariadne::viewer_builder::catalog(
2061 product_snapshot.control.at(
"snapshot_id").get<std::string>()
2063 const std::string projection_id
2064 = projection.at(
"projection_id").get<std::string>();
2065 const fs::path projection_directory = config.site_output /
"projections";
2066 const fs::path projection_path
2067 = projection_directory / (projection_id +
".json");
2068 const fs::path control_path
2069 = projection_directory / (projection_id +
".control.json");
2070 const std::string generated_at =
utc_now();
2071 const std::string settings_hash = arachne::crypto::sha256(
2072 arachnespace::contracts::canonical_json(
2073 required_object(config.document,
"publication")
2076 const fs::path storage_ref
2077 = projection_path.lexically_relative(config.site_output);
2078 const ordered_json projection_control
2079 = arachne::ariadne::viewer_builder::write_projection(
2080 projection, projection_path, storage_ref.generic_string(),
2081 settings_hash, generated_at
2085 arachnespace::contracts::canonical_json(projection_control) +
"\n",
true
2087 const ordered_json site_bundle
2088 = arachne::ariadne::viewer_builder::build_site(
2089 projection, catalog, config.viewer_templates, config.site_output,
2094 {
"command",
"viewer-build" },
2095 {
"projection_control", projection_control },
2096 {
"projection_control_path", control_path.generic_string() },
2097 {
"site_bundle", site_bundle },
2105 {
"format_version", 1 },
2107 {
"candidate-plan",
"candidate-rebuild",
"cocoon-transition",
2108 "contract-validate",
"fetch",
"fetch-plan-translate",
2109 "inbox-baseline",
"inbox-verify",
"intake",
2110 "product-check-inbox",
"product-apply-inbox",
2111 "product-rebuild-merge-hints",
2116int dispatch(
const std::vector<std::string>& arguments) {
2117 if (arguments.size() == 2U && arguments[1] ==
"--capabilities-json") {
2118 emit(capabilities());
2121 if (arguments.size() < 2U) {
2122 throw cli_error(
"a command is required");
2125 if (arguments[1] ==
"contract" && arguments.size() >= 3U
2126 && arguments[2] ==
"validate") {
2128 options(arguments, 3U, {
"--config",
"--contract",
"--input" })
2131 if (arguments[1] ==
"intake") {
2134 {
"--config",
"--payload",
"--submission-ref",
"--title",
2138 if (arguments[1] ==
"cocoon" && arguments.size() >= 3U
2139 && arguments[2] ==
"transition") {
2142 {
"--config",
"--envelope-id",
"--to",
"--actor-ref",
"--reason" }
2145 if (arguments[1] ==
"inbox" && arguments.size() >= 3U
2146 && arguments[2] ==
"baseline") {
2149 if (arguments[1] ==
"inbox" && arguments.size() >= 3U
2150 && arguments[2] ==
"verify") {
2153 if (arguments[1] ==
"product" && arguments.size() >= 3U
2154 && arguments[2] ==
"check-inbox") {
2155 static_cast<
void>(options(arguments, 3U, {}));
2158 if (arguments[1] ==
"product" && arguments.size() >= 3U
2159 && arguments[2] ==
"apply-inbox") {
2160 static_cast<
void>(options(arguments, 3U, {}));
2163 if (arguments[1] ==
"product" && arguments.size() >= 3U
2164 && arguments[2] ==
"rebuild-merge-hints") {
2165 static_cast<
void>(options(arguments, 3U, {}));
2168 if (arguments[1] ==
"candidate" && arguments.size() >= 3U
2169 && arguments[2] ==
"rebuild") {
2171 options(arguments, 3U, {
"--config",
"--plan-control",
"--run-id" })
2174 if (arguments[1] ==
"candidate" && arguments.size() >= 3U
2175 && arguments[2] ==
"plan") {
2178 {
"--config",
"--external-graph",
"--product-snapshot",
2179 "--output-artifact",
"--output-control" }
2182 if (arguments[1] ==
"fetch" && arguments.size() >= 3U
2183 && arguments[2] ==
"plan") {
2185 arguments, 3U, {
"--config",
"--plan",
"--output-directory" }
2188 if (arguments[1] ==
"fetch") {
2190 arguments, 2U, {
"--config",
"--request",
"--output-control" }
2193 if (arguments[1] ==
"viewer" && arguments.size() >= 3U
2194 && arguments[2] ==
"build") {
2197 {
"--config",
"--product-snapshot",
"--candidate-snapshot" }
2200 throw cli_error(
"unknown operations command");
2205int main(
const int argc,
char** argv) {
2206 std::vector<std::string> arguments;
2207 arguments.reserve(
static_cast<std::size_t>(argc));
2208 for (
int index = 0; index < argc; ++index) {
2209 arguments.emplace_back(argv[index]);
2212 return dispatch(arguments);
2213 }
catch (
const cli_error& error) {
2214 if (arguments.size() >= 2U && arguments[1] ==
"intake") {
2215 emit(ordered_json { {
"status",
"fail" } });
2217 std::cerr <<
"arachne: " << error.what() <<
'\n';
2219 }
catch (
const std::exception& error) {
2220 if (arguments.size() >= 2U && arguments[1] ==
"intake") {
2221 emit(ordered_json { {
"status",
"fail" } });
2223 std::cerr <<
"arachne: " << error.what() <<
'\n';
int exit_code() const noexcept
cli_error(std::string message, const int exit_code=2)
bool flag(const std::string_view name) const
const std::string & require(const std::string_view name) const
std::map< std::string, std::string, std::less<> > values_
std::optional< std::string > optional(const std::string_view name) const
std::set< std::string, std::less<> > present_flags_
options(const std::vector< std::string > &arguments, const std::size_t first, const std::initializer_list< std::string_view > allowed, const std::initializer_list< std::string_view > flags={})
envelope_record intake(const intake_request &request)
void capture_inbox_baseline(const std::filesystem::path &inbox_root)
snapshot_result replace_candidate_snapshot(const candidate_snapshot_request &request)
acquired_artifact_v1 execute(const fetch_request_v1 &request) const
int main(const int argc, char **argv)
std::string join_strings(const std::span< const std::string > values, const std::string_view separator)
int command_inbox_baseline(const options &arguments)
const json & required_object(const json &parent, const std::string_view key)
std::string validation_details(const arachnespace::contracts::validation_result &result, const std::string_view description)
int command_fetch(const options &arguments)
std::size_t size_value(const json &object, const std::string_view key, const std::size_t fallback)
written_run_manifest write_graph_run_manifest(const configuration &config, const std::string_view domain_directory, const std::string_view graph_domain, const std::string_view run_id, ordered_json configuration_hashes, ordered_json inputs, const arachne::penelope::snapshot_result &snapshot)
void emit(const json &document)
void atomic_write(const fs::path &destination, const std::string_view bytes, const bool replace)
int command_contract_validate(const options &arguments)
ordered_json envelope_json(const arachne::coordination::envelope_record &envelope)
json candidate_tables_for_viewer(const json &tables)
fs::path resolved_path(const fs::path &path, const fs::path &relative_root)
ordered_json wikidata_point_fetch_request(const configuration &config, const json &plan, const json &planned, const std::span< const std::string > entities, const std::string &request_id)
std::string read_bytes(const fs::path &path, const std::uintmax_t maximum_bytes, const std::string_view description)
constexpr std::uintmax_t maximum_config_bytes
resolved_snapshot_export resolve_snapshot_export(const configuration &config, const fs::path &control_path, const arachnespace::contracts::contract_name expected_contract, const std::string_view expected_kind)
std::string form_encode(const std::string_view value)
void write_immutable_exact(const fs::path &destination, const std::string_view bytes, const std::string_view description)
ordered_json snapshot_json(const arachne::penelope::snapshot_result &snapshot)
int command_candidate_rebuild(const options &arguments)
int command_fetch_plan(const options &arguments)
void verify_external_source_snapshot(const configuration &config, const json &external_graph)
arachne::ariadne::candidate_configuration candidate_configuration_from(const configuration &config)
constexpr std::uintmax_t maximum_export_bytes
std::optional< fs::path > conventional_legacy_inbox()
int command_intake(const options &arguments)
int command_cocoon_transition(const options &arguments)
json materialize_jsonl_export(const fs::path &path, const bool candidate)
ordered_json actor_inbox_issues(const arachne::coordination::operational_ledger &ledger, const fs::path &inbox)
int dispatch(const std::vector< std::string > &arguments)
std::string policy_configuration_hash(const configuration &config, const std::string_view section)
bool path_is_in_protected_legacy(const fs::path &path, const configuration &config)
ordered_json wikidata_bulk_fetch_request(const json &plan, const json &planned, std::string request_id, std::string locator)
fs::path resolve_plan_artifact(const configuration &config, const fs::path &control_path, const std::string_view storage_ref)
bool wikidata_entity_id(const std::string_view value)
configuration load_configuration(const fs::path &input_path)
fs::path repository_root()
std::atomic< std::uint64_t > temporary_sequence
ordered_json capabilities()
bool valid_logical_date(const std::string_view value)
int command_candidate_plan(const options &arguments)
int command_product_rebuild_merge_hints()
int command_viewer_build(const options &arguments)
json parse_embedded_json(const json &row, const std::string_view key, json fallback)
ordered_json issue_json(std::string path, std::string message)
bool path_has_symlink(const fs::path &root, const fs::path &candidate)
void verify_product_coverage(const json &external_graph, const json &product_tables)
int command_inbox_verify(const options &arguments)
std::uint64_t unsigned_value(const json &object, const std::string_view key, const std::uint64_t default_value, const bool positive)
int command_product_inbox(const bool apply)
fs::path command_path(const std::string &value)
json read_json(const fs::path &path, const std::uintmax_t maximum_bytes, const std::string_view description)
constexpr std::uintmax_t maximum_control_bytes
std::string sha256(std::span< const std::byte > bytes)
std::string sha256_file(const std::filesystem::path &path)
inbox_result apply_product_inbox(const std::filesystem::path &repository_root)
inbox_result check_product_inbox(const std::filesystem::path &repository_root)
std::size_t rebuild_product_merge_hints(const std::filesystem::path &repository_root)
@ research_candidate_graph_plan
std::optional< fs::path > legacy_inbox
fs::path viewer_templates
std::uintmax_t submission_max_bytes
std::vector< fs::path > protected_legacy_inboxes
std::chrono::seconds product_lock_stale
std::chrono::seconds candidate_lock_stale
int gray_bonus_basis_points
std::uintmax_t byte_length
std::string submission_ref
std::string payload_sha256
std::string export_sha256
std::size_t skipped_inputs
std::size_t applied_inputs
std::string database_sha256
std::string error_message
bool delivered() const noexcept