mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat(cross-repo): report run status and skipped legacy stores (#2133)
Two follow-ups to the #2133 wildcard fix, both about what cross-repo intelligence tells the caller. 1. "Never run" vs "ran, no links". An empty CROSS_* set used to mean either one, and no output surface could tell them apart. Every run now records a marker in the SOURCE store's store_meta (key cross_repo_last_run:<project>, same per-project key pattern as coverage_shadow_fp:<project>) once the previous CROSS_* generation has been cleaned: last_run_at, outcome (complete|cancelled|failed), targets, projects_scanned, total_cross_edges, skipped_projects. index_status and the UI layout (next to linked_projects) now carry a `cross_repo` object: {"status":"never_run"} or {"status":"ran", ...}. The marker lives beside the edges it describes, so a reindex that rebuilds the store drops both together. No new database files and no new freshness key. New store API cbm_store_meta_get/put. put seeds a missing store_meta through the generation path, because creating the table without db_uid/mutation_gen would make cbm_store_generation() report malformed metadata. 2. ["*"] skips a pre-#768 store (since #2343) but only logged it. The result now names each one, and the MCP response lists them: skipped_projects: [{name, reason:"pre_768_schema", hint:"reindex this project"}] (only emitted when non-empty). The cross_repo.project_unusable log line is unchanged. cr_project_exists() is split into cr_project_state() (usable / missing / legacy) so that the enumerator can tell a legacy store apart from a foreign .db file. New allocations go through mem_core. Refs #2133 Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
0f52d30c29
commit
e4f9735dea
9 files changed
+476
-11
No files matched your search
@@ -213,6 +213,8 @@ The install script placed beside the binary is **reported, not deleted** — uni
|
||||
- **`CROSS_*` edges** link nodes across multiple repos indexed under the same store
|
||||
- **Multi-galaxy 3D UI layout** for cross-repo architecture visualization
|
||||
- **Cross-repo architecture summary** combining services, routes, and dependencies across the indexed fleet
|
||||
- **Run status**: `index_status` (and the UI layout next to `linked_projects`) carries a `cross_repo` object, `{"status":"never_run"}` until `index_repository(mode="cross-repo-intelligence")` has run with that project as the source, then `"ran"` with `last_run_at`, `outcome`, `targets`, `projects_scanned`, `total_cross_edges` and `skipped_projects`. That is how you tell "never linked" apart from "linked, nothing matched". The status is stored in the project's own index next to its `CROSS_*` edges, so a reindex that rebuilds the index drops both at once and the status goes back to `never_run`.
|
||||
- **Skipped stores**: `target_projects: ["*"]` skips an index built before the #768 schema change rather than failing the whole run. The response lists each one as `skipped_projects: [{"name", "reason": "pre_768_schema", "hint": "reindex this project"}]`.
|
||||
|
||||
### Edge types (selected)
|
||||
- `CALLS` — a callable is invoked at the source site
|
||||
|
||||
@@ -6906,6 +6906,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
|
||||
}
|
||||
add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL,
|
||||
coverage_samples);
|
||||
(void)cbm_cross_repo_add_status_json(doc, root, store, project);
|
||||
safe_str_free(&proj_info.name);
|
||||
safe_str_free(&proj_info.indexed_at);
|
||||
safe_str_free(&proj_info.root_path);
|
||||
@@ -10054,6 +10055,7 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path
|
||||
yyjson_doc_free(jdoc);
|
||||
|
||||
if (result.no_targets || result.failed) {
|
||||
cbm_cross_repo_result_free(&result);
|
||||
free(project);
|
||||
return cbm_mcp_text_result(
|
||||
result.no_targets
|
||||
@@ -10091,9 +10093,23 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path
|
||||
yyjson_mut_obj_add_int(doc, root, "cross_trpc_calls", result.trpc_edges);
|
||||
yyjson_mut_obj_add_int(doc, root, "total_cross_edges", total);
|
||||
yyjson_mut_obj_add_real(doc, root, "elapsed_ms", result.elapsed_ms);
|
||||
/* ["*"] skips stores it cannot link (#2133); name them so a partial fleet
|
||||
* is visible instead of silently smaller. Only emitted when non-empty. */
|
||||
if (result.skipped_count > 0) {
|
||||
yyjson_mut_val *skipped = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < result.skipped_count; i++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_strcpy(doc, item, "name", result.skipped_projects[i].project);
|
||||
yyjson_mut_obj_add_str(doc, item, "reason", result.skipped_projects[i].reason);
|
||||
yyjson_mut_obj_add_str(doc, item, "hint", "reindex this project");
|
||||
yyjson_mut_arr_append(skipped, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root, "skipped_projects", skipped);
|
||||
}
|
||||
|
||||
char *json = yy_doc_to_str(doc);
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_cross_repo_result_free(&result);
|
||||
free(project);
|
||||
char *out = cbm_mcp_text_result(json, result.cancelled);
|
||||
free(json);
|
||||
|
||||
+158
-11
@@ -13,12 +13,14 @@
|
||||
#include "pipeline/pipeline_internal.h" // cbm_route_canon_path
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/mem_core.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/str_util.h"
|
||||
|
||||
#include <sqlite3/sqlite3.h>
|
||||
#include <yyjson/yyjson.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -153,27 +155,38 @@ static bool cr_store_has_exact_project(cbm_store_t *store, const char *project)
|
||||
return matches;
|
||||
}
|
||||
|
||||
/* True when `project` is a usable cross-repo input: its store holds exactly
|
||||
* that project AND carries the schema the read-write open in
|
||||
typedef enum {
|
||||
CR_PROJECT_USABLE = 0,
|
||||
CR_PROJECT_MISSING, /* no store, or the store is not exactly this project */
|
||||
CR_PROJECT_LEGACY, /* exact project, but a pre-#768 schema (reindex required) */
|
||||
} cr_project_state_t;
|
||||
|
||||
/* Classify `project` as a cross-repo input: usable when its store holds
|
||||
* exactly that project AND carries the schema the read-write open in
|
||||
* cr_open_existing_project requires. Checking only the first let a pre-#768
|
||||
* store (still readable, so list_projects shows it) pass validation and the
|
||||
* ["*"] enumeration, then abort the whole run at its write open — after the
|
||||
* source's previous CROSS_* generation had already been deleted. The probe is
|
||||
* read-only, so the legacy store is left untouched for its reindex. (#2133) */
|
||||
static bool cr_project_exists(const char *project) {
|
||||
static cr_project_state_t cr_project_state(const char *project) {
|
||||
char path[CR_PATH_BUF];
|
||||
if (!cr_db_path(project, path, sizeof(path))) {
|
||||
return false;
|
||||
return CR_PROJECT_MISSING;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path_query(path);
|
||||
bool exists = cr_store_has_exact_project(store, project);
|
||||
if (exists && !cbm_store_edges_schema_current(store)) {
|
||||
cr_project_state_t state =
|
||||
cr_store_has_exact_project(store, project) ? CR_PROJECT_USABLE : CR_PROJECT_MISSING;
|
||||
if (state == CR_PROJECT_USABLE && !cbm_store_edges_schema_current(store)) {
|
||||
cbm_log_warn("cross_repo.project_unusable", "project", project, "reason",
|
||||
"pre_768_schema_reindex_required");
|
||||
exists = false;
|
||||
state = CR_PROJECT_LEGACY;
|
||||
}
|
||||
cbm_store_close(store);
|
||||
return exists;
|
||||
return state;
|
||||
}
|
||||
|
||||
static bool cr_project_exists(const char *project) {
|
||||
return cr_project_state(project) == CR_PROJECT_USABLE;
|
||||
}
|
||||
|
||||
static cbm_store_t *cr_open_existing_project(const char *project) {
|
||||
@@ -1139,8 +1152,33 @@ static cr_match_result_t match_typed_routes(cbm_store_t *src_store, const char *
|
||||
|
||||
static void free_project_list(char **projects, int count);
|
||||
|
||||
/* Remember a store the ["*"] enumeration skipped, so the caller can name it
|
||||
* instead of the run silently covering fewer projects than exist. */
|
||||
static bool cr_record_skip(cbm_cross_repo_result_t *result, const char *project,
|
||||
const char *reason) {
|
||||
if (!result) {
|
||||
return true;
|
||||
}
|
||||
cbm_cross_repo_skip_t *grown =
|
||||
cbm_realloc(CBM_MEM_CLASS_OTHER, result->skipped_projects,
|
||||
((size_t)result->skipped_count + 1U) * sizeof(*result->skipped_projects));
|
||||
if (!grown) {
|
||||
return false;
|
||||
}
|
||||
result->skipped_projects = grown;
|
||||
char *name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, project);
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
grown[result->skipped_count].project = name;
|
||||
grown[result->skipped_count].reason = reason;
|
||||
result->skipped_count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* When target_projects = ["*"], scan the cache directory for all .db files. */
|
||||
static int collect_all_projects(char ***out, cr_run_context_t *ctx) {
|
||||
static int collect_all_projects(char ***out, cr_run_context_t *ctx,
|
||||
cbm_cross_repo_result_t *result) {
|
||||
*out = NULL;
|
||||
const char *dir = cr_cache_dir();
|
||||
cbm_dir_t *d = cbm_opendir(dir);
|
||||
@@ -1188,7 +1226,15 @@ static int collect_all_projects(char ***out, cr_run_context_t *ctx) {
|
||||
}
|
||||
memcpy(project, ent->name, project_length);
|
||||
project[project_length] = '\0';
|
||||
if (!cbm_validate_project_name(project) || !cr_project_exists(project)) {
|
||||
if (!cbm_validate_project_name(project)) {
|
||||
continue;
|
||||
}
|
||||
cr_project_state_t state = cr_project_state(project);
|
||||
if (state == CR_PROJECT_LEGACY && !cr_record_skip(result, project, "pre_768_schema")) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
if (state != CR_PROJECT_USABLE) {
|
||||
continue;
|
||||
}
|
||||
if (count >= cap) {
|
||||
@@ -1273,6 +1319,106 @@ static int collect_named_projects(const char **targets, int target_count, char *
|
||||
return unique_count;
|
||||
}
|
||||
|
||||
/* ── Last-run marker ─────────────────────────────────────────────── */
|
||||
|
||||
static bool cr_status_key(const char *project, char *buf, size_t bufsz) {
|
||||
int written = snprintf(buf, bufsz, "cross_repo_last_run:%s", project);
|
||||
return written > 0 && (size_t)written < bufsz;
|
||||
}
|
||||
|
||||
/* Record this run in the SOURCE store's store_meta, next to the CROSS_*
|
||||
* edges it just rewrote: a reindex that rebuilds the store drops both
|
||||
* together, so the marker can never outlive the links it describes. Written
|
||||
* once the source's previous CROSS_* generation was cleaned, whatever the
|
||||
* outcome, because from then on the old summary no longer matches the graph.
|
||||
* Project names are validated ([A-Za-z0-9._-]) or "*", so no JSON escaping is
|
||||
* needed. Best-effort: a marker failure is logged, never fails the run. */
|
||||
static void cr_record_last_run(cbm_store_t *src_store, const char *project, const char **targets,
|
||||
int target_count, const cbm_cross_repo_result_t *result) {
|
||||
char key[CR_QN_BUF];
|
||||
if (!cr_status_key(project, key, sizeof(key))) {
|
||||
return;
|
||||
}
|
||||
size_t cap = CR_PROPS_BUF;
|
||||
for (int i = 0; i < target_count; i++) {
|
||||
cap += (targets[i] ? strlen(targets[i]) : 0) + CR_COL_4;
|
||||
}
|
||||
char *json = cbm_alloc(CBM_MEM_CLASS_OTHER, cap);
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
char when[CBM_SZ_32];
|
||||
time_t now = time(NULL);
|
||||
struct tm tm_now;
|
||||
if (!cbm_gmtime_r(&now, &tm_now) ||
|
||||
strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm_now) == 0) {
|
||||
when[0] = '\0';
|
||||
}
|
||||
const char *outcome = result->failed ? "failed" : result->cancelled ? "cancelled" : "complete";
|
||||
size_t len = (size_t)snprintf(
|
||||
json, cap, "{\"last_run_at\":\"%s\",\"outcome\":\"%s\",\"targets\":[", when, outcome);
|
||||
for (int i = 0; i < target_count && len < cap; i++) {
|
||||
len += (size_t)snprintf(json + len, cap - len, "%s\"%s\"", i ? "," : "",
|
||||
targets[i] ? targets[i] : "");
|
||||
}
|
||||
int total = result->http_edges + result->async_edges + result->channel_edges +
|
||||
result->grpc_edges + result->graphql_edges + result->trpc_edges;
|
||||
if (len < cap) {
|
||||
len += (size_t)snprintf(json + len, cap - len,
|
||||
"],\"projects_scanned\":%d,\"total_cross_edges\":%d,"
|
||||
"\"skipped_projects\":%d}",
|
||||
result->projects_scanned, total, result->skipped_count);
|
||||
}
|
||||
if (len >= cap || cbm_store_meta_put(src_store, key, json) != CBM_STORE_OK) {
|
||||
cbm_log_warn("cross_repo.status_unrecorded", "project", project);
|
||||
}
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, json);
|
||||
}
|
||||
|
||||
bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent,
|
||||
cbm_store_t *store, const char *project) {
|
||||
char key[CR_QN_BUF];
|
||||
if (!doc || !parent || !store || !project || !cr_status_key(project, key, sizeof(key))) {
|
||||
return false;
|
||||
}
|
||||
char *raw = NULL;
|
||||
int rc = cbm_store_meta_get(store, key, &raw);
|
||||
yyjson_doc *recorded = rc == CBM_STORE_OK ? yyjson_read(raw, strlen(raw), 0) : NULL;
|
||||
cbm_free(CBM_MEM_CLASS_STORE, raw);
|
||||
yyjson_val *recorded_root = recorded ? yyjson_doc_get_root(recorded) : NULL;
|
||||
yyjson_mut_val *status = NULL;
|
||||
if (yyjson_is_obj(recorded_root)) {
|
||||
status = yyjson_val_mut_copy(doc, recorded_root);
|
||||
}
|
||||
if (!status) {
|
||||
status = yyjson_mut_obj(doc);
|
||||
}
|
||||
yyjson_doc_free(recorded);
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
/* "never_run" is the honest reading of an absent marker: no run with this
|
||||
* project as the source has completed its cleanup since the store was
|
||||
* (re)built. A target-side run still leaves its CROSS_* edges here. */
|
||||
const char *state = rc == CBM_STORE_NOT_FOUND ? "never_run"
|
||||
: yyjson_mut_obj_size(status) > 0 ? "ran"
|
||||
: "unavailable";
|
||||
yyjson_mut_obj_add_str(doc, status, "status", state);
|
||||
return yyjson_mut_obj_add_val(doc, parent, "cross_repo", status);
|
||||
}
|
||||
|
||||
void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < result->skipped_count; i++) {
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects[i].project);
|
||||
}
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects);
|
||||
result->skipped_projects = NULL;
|
||||
result->skipped_count = 0;
|
||||
}
|
||||
|
||||
static cr_run_status_t add_match_count(int *total, cr_match_result_t matched) {
|
||||
*total += matched.count;
|
||||
return matched.status;
|
||||
@@ -1304,7 +1450,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project,
|
||||
int resolved_count = 0;
|
||||
|
||||
if (target_count == SKIP_ONE && target_projects[0] && strcmp(target_projects[0], "*") == 0) {
|
||||
resolved_count = collect_all_projects(&resolved, &run);
|
||||
resolved_count = collect_all_projects(&resolved, &run, &result);
|
||||
} else {
|
||||
resolved_count = collect_named_projects(target_projects, target_count, &resolved, &run);
|
||||
}
|
||||
@@ -1442,6 +1588,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project,
|
||||
result.projects_scanned++;
|
||||
}
|
||||
|
||||
cr_record_last_run(src_store, project, target_projects, target_count, &result);
|
||||
cbm_store_close(src_store);
|
||||
|
||||
free_project_list(resolved, resolved_count);
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
#include <stdatomic.h>
|
||||
|
||||
/* A store the ["*"] enumeration skipped instead of linking. */
|
||||
typedef struct {
|
||||
char *project; /* heap-owned */
|
||||
const char *reason; /* static, e.g. "pre_768_schema" (reindex required) */
|
||||
} cbm_cross_repo_skip_t;
|
||||
|
||||
/* Result of a cross-repo matching run. */
|
||||
typedef struct {
|
||||
int http_edges; /* CROSS_HTTP_CALLS edges created */
|
||||
@@ -23,8 +29,26 @@ typedef struct {
|
||||
bool no_targets; /* failed: nothing but the source project resolved (#1133) */
|
||||
bool cancelled; /* stopped at a bounded cancellation checkpoint */
|
||||
bool partial_results; /* committed writes before cancellation were retained */
|
||||
cbm_cross_repo_skip_t *skipped_projects; /* ["*"] only; free with ..._result_free */
|
||||
int skipped_count;
|
||||
} cbm_cross_repo_result_t;
|
||||
|
||||
/* Release the heap-owned parts of a result (safe on a zeroed result). */
|
||||
void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result);
|
||||
|
||||
struct yyjson_mut_doc;
|
||||
struct yyjson_mut_val;
|
||||
|
||||
/* Add a "cross_repo" object to `parent` describing the last cross-repo run
|
||||
* with `project` as the source, as recorded in that project's store:
|
||||
* {"status":"never_run"} -- no run recorded since the store was (re)built
|
||||
* {"status":"ran","last_run_at","outcome","targets","projects_scanned",
|
||||
* "total_cross_edges","skipped_projects"}
|
||||
* Read-only (works on a query store). Returns false only on invalid input or
|
||||
* allocation failure. */
|
||||
bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent,
|
||||
cbm_store_t *store, const char *project);
|
||||
|
||||
/* Run cross-repo matching for `project` against `target_projects`.
|
||||
* If target_count == 1 and target_projects[0] == "*", matches against all
|
||||
* indexed projects. Writes CROSS_* edges bidirectionally into both the
|
||||
|
||||
@@ -2456,6 +2456,77 @@ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out) {
|
||||
if (!out) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
*out = NULL;
|
||||
if (!s || !s->db || !key) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_stmt *get = NULL;
|
||||
/* A store that never advanced its generation has no store_meta table:
|
||||
* that is "no value recorded", not an error. */
|
||||
if (sqlite3_prepare_v2(s->db, "SELECT v FROM store_meta WHERE k = ?1;", CBM_NOT_FOUND, &get,
|
||||
NULL) != SQLITE_OK) {
|
||||
sqlite3_finalize(get);
|
||||
return CBM_STORE_NOT_FOUND;
|
||||
}
|
||||
bind_text(get, SKIP_ONE, key);
|
||||
int rc = sqlite3_step(get);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char *value = (const char *)sqlite3_column_text(get, 0);
|
||||
*out = cbm_mem_strdup(CBM_MEM_CLASS_STORE, value ? value : "");
|
||||
sqlite3_finalize(get);
|
||||
return *out ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_finalize(get);
|
||||
return rc == SQLITE_DONE ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value) {
|
||||
static const char savepoint[] = "SAVEPOINT cbm_meta_put;";
|
||||
static const char rollback[] = "ROLLBACK TO cbm_meta_put;";
|
||||
static const char release[] = "RELEASE cbm_meta_put;";
|
||||
if (!s || !s->db || !key || !value) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (exec_sql(s, savepoint) != CBM_STORE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
/* store_meta is only ever created by the generation seed, which writes
|
||||
* db_uid + mutation_gen with it. Creating the table here without them
|
||||
* would make cbm_store_generation() report malformed metadata, so an
|
||||
* absent table is seeded through that one path. */
|
||||
bool absent = false;
|
||||
char uid[17] = {0};
|
||||
char counter[21] = {0};
|
||||
uint64_t counter_value = 0;
|
||||
if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK ||
|
||||
(absent && cbm_store_generation_advance(s) != CBM_STORE_OK)) {
|
||||
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||
}
|
||||
sqlite3_stmt *set = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, "INSERT OR REPLACE INTO store_meta (k, v) VALUES (?1, ?2);",
|
||||
CBM_NOT_FOUND, &set, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "store_meta put prepare");
|
||||
sqlite3_finalize(set);
|
||||
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||
}
|
||||
bind_text(set, SKIP_ONE, key);
|
||||
bind_text(set, ST_COL_2, value);
|
||||
int rc = sqlite3_step(set);
|
||||
sqlite3_finalize(set);
|
||||
if (rc != SQLITE_DONE) {
|
||||
store_set_error_sqlite(s, "store_meta put");
|
||||
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||
}
|
||||
if (exec_sql(s, release) != CBM_STORE_OK) {
|
||||
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_get_project(cbm_store_t *s, const char *name, cbm_project_t *out) {
|
||||
sqlite3_stmt *stmt =
|
||||
prepare_cached(s, &s->stmt_get_project,
|
||||
|
||||
@@ -462,6 +462,17 @@ int cbm_store_generation_advance(cbm_store_t *s);
|
||||
* an error. */
|
||||
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz);
|
||||
|
||||
/* Per-store feature metadata in store_meta (key/value text). Keys are
|
||||
* namespaced by the owning feature, e.g. "cross_repo_last_run:<project>".
|
||||
* get: CBM_STORE_OK with *out owned by the caller (release with
|
||||
* cbm_free(CBM_MEM_CLASS_STORE, *out)), or
|
||||
* CBM_STORE_NOT_FOUND when the key -- or store_meta itself -- is absent.
|
||||
* Read-only: safe on a store opened for query.
|
||||
* put: upserts the key; seeds store_meta through the generation path when a
|
||||
* legacy store lacks it. Atomic (savepoint); needs a read-write store. */
|
||||
int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out);
|
||||
int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value);
|
||||
|
||||
/* Seal a fully-written staging database before atomic publication.
|
||||
* Raises synchronous to FULL, requires an exclusive TRUNCATE checkpoint to
|
||||
* complete, then leaves the database in verified DELETE journal mode so the
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "ui/layout3d.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "store/store.h"
|
||||
#include "pipeline/pass_cross_repo.h"
|
||||
#include "watcher/watcher.h"
|
||||
#include "cli/cli.h"
|
||||
#include "git/git_context.h"
|
||||
@@ -1729,6 +1730,9 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
free(linked[li]);
|
||||
}
|
||||
|
||||
/* Next to linked_projects: an empty list alone cannot tell "never linked"
|
||||
* from "linked, nothing matched". */
|
||||
(void)cbm_cross_repo_add_status_json(mdoc, mroot, store, project);
|
||||
cbm_store_close(store);
|
||||
yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "pipeline/pipeline_internal.h"
|
||||
|
||||
#include <sqlite3/sqlite3.h>
|
||||
#include <yyjson/yyjson.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -401,6 +402,96 @@ TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Render the "cross_repo" status object the MCP/UI surfaces attach for
|
||||
* `project`, read from its store the way index_status does (query open). */
|
||||
static char *cross_repo_status_json(const cross_repo_fixture_t *fixture, const char *project) {
|
||||
char path[512];
|
||||
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path_query(path);
|
||||
if (!store) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
char *json = NULL;
|
||||
if (cbm_cross_repo_add_status_json(doc, root, store, project)) {
|
||||
json = yyjson_mut_write(doc, 0, NULL);
|
||||
}
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_store_close(store);
|
||||
return json;
|
||||
}
|
||||
|
||||
/* #2133 follow-up: an empty CROSS_* set could mean "never linked" or "linked,
|
||||
* nothing matched" and no surface told them apart. The source store records
|
||||
* each run; before any run the status says so explicitly. */
|
||||
TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_create_project(&fixture, "status-src") &&
|
||||
cross_repo_create_project(&fixture, "status-api");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed status fixture");
|
||||
}
|
||||
|
||||
char *before = cross_repo_status_json(&fixture, "status-src");
|
||||
const char *targets[] = {"status-api"};
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("status-src", targets, 1);
|
||||
char *after = cross_repo_status_json(&fixture, "status-src");
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
bool before_never = before && strstr(before, "\"cross_repo\":{\"status\":\"never_run\"}");
|
||||
bool after_ran =
|
||||
after && strstr(after, "\"status\":\"ran\"") && strstr(after, "\"outcome\":\"complete\"") &&
|
||||
strstr(after, "\"targets\":[\"status-api\"]") && strstr(after, "\"projects_scanned\":1") &&
|
||||
strstr(after, "\"total_cross_edges\":0") && strstr(after, "\"last_run_at\":\"20");
|
||||
free(before);
|
||||
free(after);
|
||||
cbm_cross_repo_result_free(&result);
|
||||
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_TRUE(before_never);
|
||||
ASSERT_TRUE(after_ran);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2133 follow-up: ["*"] skips a pre-#768 store (it cannot be linked until a
|
||||
* reindex); the result must name it rather than silently covering fewer
|
||||
* projects, and the recorded status counts it. */
|
||||
TEST(cross_repo_wildcard_reports_skipped_pre768_store) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "skip-src", "skip-api", "/orders", "s") &&
|
||||
cross_repo_create_pre768_project(&fixture, "aa-pre768-store");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed skipped-store fixture");
|
||||
}
|
||||
|
||||
const char *targets[] = {"*"};
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("skip-src", targets, 1);
|
||||
char *status = cross_repo_status_json(&fixture, "skip-src");
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
bool named = result.skipped_count == 1 && result.skipped_projects &&
|
||||
strcmp(result.skipped_projects[0].project, "aa-pre768-store") == 0 &&
|
||||
strcmp(result.skipped_projects[0].reason, "pre_768_schema") == 0;
|
||||
bool status_counts =
|
||||
status && strstr(status, "\"skipped_projects\":1") && strstr(status, "\"targets\":[\"*\"]");
|
||||
free(status);
|
||||
cbm_cross_repo_result_free(&result);
|
||||
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_EQ(result.projects_scanned, 1);
|
||||
ASSERT_TRUE(named);
|
||||
ASSERT_TRUE(status_counts);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool cross_repo_seed_bounded_scan(const cross_repo_fixture_t *fixture,
|
||||
const char *source_project, const char *target_project) {
|
||||
enum { TEST_SCAN_ROWS = 4097 };
|
||||
@@ -836,6 +927,8 @@ SUITE(cross_repo) {
|
||||
RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens);
|
||||
RUN_TEST(cross_repo_wildcard_skips_pre768_store_issue2133);
|
||||
RUN_TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133);
|
||||
RUN_TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links);
|
||||
RUN_TEST(cross_repo_wildcard_reports_skipped_pre768_store);
|
||||
RUN_TEST(cross_repo_scan_bound_counts_examined_rows_not_matches);
|
||||
RUN_TEST(cross_repo_propagates_delete_failure);
|
||||
RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted);
|
||||
|
||||
@@ -13033,6 +13033,102 @@ TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A store written before the #768 edges.local_name_gen column: readable, but
|
||||
* refused by every read-write open until a reindex. */
|
||||
static bool mcp_cross_repo_create_pre768_store(const char *cache, const char *project) {
|
||||
char db_path[CBM_SZ_1K];
|
||||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||||
sqlite3 *db = NULL;
|
||||
if (sqlite3_open(db_path, &db) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
char sql[CBM_SZ_1K];
|
||||
snprintf(sql, sizeof(sql),
|
||||
"CREATE TABLE projects(name TEXT PRIMARY KEY, indexed_at TEXT NOT NULL,"
|
||||
" root_path TEXT NOT NULL);"
|
||||
"CREATE TABLE nodes(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL,"
|
||||
" label TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL,"
|
||||
" file_path TEXT DEFAULT '', start_line INTEGER DEFAULT 0,"
|
||||
" end_line INTEGER DEFAULT 0, properties TEXT DEFAULT '{}',"
|
||||
" UNIQUE(project, qualified_name));"
|
||||
"CREATE TABLE edges(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL,"
|
||||
" source_id INTEGER NOT NULL, target_id INTEGER NOT NULL, type TEXT NOT NULL,"
|
||||
" properties TEXT DEFAULT '{}', UNIQUE(source_id, target_id, type));"
|
||||
"INSERT INTO projects VALUES('%s', '2026-06-01T00:00:00Z', '/pre768');",
|
||||
project);
|
||||
bool ok = sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK;
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* #2133 follow-up, both user-visible halves: index_status tells "never run"
|
||||
* from "ran" via a `cross_repo` object, and a ["*"] run names the legacy
|
||||
* store it skipped (with the reindex hint) instead of silently linking fewer
|
||||
* projects. */
|
||||
TEST(tool_cross_repo_status_and_wildcard_skipped_projects) {
|
||||
char cache[256];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-status-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
char *source_project = cbm_project_name_from_path(cache);
|
||||
ASSERT_NOT_NULL(source_project);
|
||||
const char *target_project = "cross-status-target";
|
||||
const char *legacy_project = "cross-status-pre768";
|
||||
ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache));
|
||||
ASSERT_TRUE(mcp_cross_repo_create_pre768_store(cache, legacy_project));
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||||
|
||||
char status_args[CBM_SZ_1K];
|
||||
snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\",\"format\":\"json\"}",
|
||||
source_project);
|
||||
char *before = cbm_mcp_handle_tool(srv, "index_status", status_args);
|
||||
bool before_never =
|
||||
response_contains_json_fragment(before, "\"cross_repo\":{\"status\":\"never_run\"}");
|
||||
|
||||
char args[CBM_SZ_2K];
|
||||
snprintf(args, sizeof(args),
|
||||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||||
"\"target_projects\":[\"*\"]}",
|
||||
cache);
|
||||
char *run = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||||
bool run_ok = run && strstr(run, "\"isError\":true") == NULL &&
|
||||
response_contains_json_fragment(run, "\"total_cross_edges\":1");
|
||||
bool skipped_named = response_contains_json_fragment(
|
||||
run, "\"skipped_projects\":[{\"name\":\"cross-status-pre768\","
|
||||
"\"reason\":\"pre_768_schema\",\"hint\":\"reindex this project\"}]");
|
||||
|
||||
char *after = cbm_mcp_handle_tool(srv, "index_status", status_args);
|
||||
bool after_ran = response_contains_json_fragment(after, "\"status\":\"ran\"") &&
|
||||
response_contains_json_fragment(after, "\"total_cross_edges\":1") &&
|
||||
response_contains_json_fragment(after, "\"skipped_projects\":1");
|
||||
|
||||
free(before);
|
||||
free(run);
|
||||
free(after);
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_project_db(cache, source_project);
|
||||
cleanup_project_db(cache, target_project);
|
||||
cleanup_project_db(cache, legacy_project);
|
||||
free(source_project);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
cbm_rmdir(cache);
|
||||
|
||||
ASSERT_TRUE(before_never);
|
||||
ASSERT_TRUE(run_ok);
|
||||
ASSERT_TRUE(skipped_named);
|
||||
ASSERT_TRUE(after_ran);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* `name` is the documented index project-name override and must identify the
|
||||
* cross-repo source too. Deriving from repo_path here makes custom-named
|
||||
* projects impossible to rescan even though ordinary indexing created them. */
|
||||
@@ -21212,6 +21308,7 @@ SUITE(mcp_mutation_guard) {
|
||||
RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases);
|
||||
RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases);
|
||||
RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting);
|
||||
RUN_TEST(tool_cross_repo_status_and_wildcard_skipped_projects);
|
||||
RUN_TEST(tool_cross_repo_honors_source_name_override);
|
||||
RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested);
|
||||
RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal);
|
||||
|
||||
Reference in new issue
Block a user